Compare commits

..
78 Commits
Author SHA1 Message Date
izzy 25025da70a Merge branch 'main' into 3-otel 2026-04-01 21:14:44 +01: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 (andTom 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
LazyCatandTom 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
TomandChris Hultin 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]andgithub-actions[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]andgithub-actions[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]andgithub-actions[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]andgithub-actions[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
izzy d1631f6262 test: correct image size of extra bytes jpeg test
Signed-off-by: izzy <me@insrt.uk>
2026-01-25 20:22:47 +00:00
izzy 94aaeed758 chore: remove un-used import
Signed-off-by: izzy <me@insrt.uk>
2026-01-25 19:49:36 +00:00
izzy 945b7a6ecb Merge branch '2-refactor-core-files' into 3-otel 2026-01-25 19:49:08 +00:00
izzy 6418da51cd Merge branch 'main' into 2-refactor-core-files
# Conflicts:
#	Cargo.lock
2026-01-25 19:49:08 +00:00
izzy 74e36c7c2a Merge branch '2-refactor-core-files' into 3-otel 2026-01-24 23:44:18 +00:00
izzy 239112f935 Merge branch '1-mise-git-town' into 2-refactor-core-files 2026-01-24 23:44:17 +00:00
izzy 5968514231 Merge branch 'main' into 1-mise-git-town 2026-01-24 23:44:16 +00:00
izzy 0c3f76a406 Merge branch '2-refactor-core-files' into 3-otel 2026-01-24 23:36:45 +00:00
izzy f988b09d9a Merge branch '1-mise-git-town' into 2-refactor-core-files 2026-01-24 23:36:44 +00:00
izzy facaf3869f Merge branch 'main' into 1-mise-git-town 2026-01-24 23:36:42 +00:00
izzy c96f89aaa9 Merge branch '2-refactor-core-files' into 3-otel 2026-01-24 23:35:50 +00:00
izzy 03d790007d Merge branch '1-mise-git-town' into 2-refactor-core-files 2026-01-24 23:35:49 +00:00
izzy 270a9ac625 Merge branch 'main' into 1-mise-git-town 2026-01-24 23:35:48 +00:00
izzy 6acc7a3e46 Merge branch '2-refactor-core-files' into 3-otel 2026-01-24 02:18:54 +00:00
izzy e6a2571303 Merge branch '1-mise-git-town' into 2-refactor-core-files 2026-01-24 02:18:53 +00:00
izzy ef36df5da1 Merge branch 'main' into 1-mise-git-town 2026-01-24 02:18:51 +00:00
izzy d552442b43 Merge branch '2-refactor-core-files' into 3-otel 2026-01-24 02:11:10 +00:00
izzy 0fb4981556 Merge branch '1-mise-git-town' into 2-refactor-core-files 2026-01-24 02:11:09 +00:00
izzy 6b00c5aa42 Merge branch 'main' into 1-mise-git-town 2026-01-24 02:11:08 +00:00
izzy 503885d8dc Merge branch '1-mise-git-town' into 2-refactor-core-files 2026-01-24 02:02:55 +00:00
izzy e896bec16a Merge branch 'main' into 1-mise-git-town 2026-01-24 02:02:54 +00:00
izzy 7468ba5b8a trigger workflow
Signed-off-by: izzy <me@insrt.uk>
2026-01-24 02:01:30 +00:00
izzy d9ca1b8805 trigger workflow 2026-01-24 01:57:27 +00:00
izzy 97511059a4 feat: add otel logging, integrate with tracing 2026-01-24 01:27:32 +00:00
izzy e896582a5c feat: repository architecture for files crate w. added tests
Signed-off-by: izzy <me@insrt.uk>
2026-01-24 01:25:42 +00:00
izzy 9c2ae59ecf chore: add git-town tool
Signed-off-by: izzy <me@insrt.uk>
2026-01-24 01:21:33 +00:00
109 changed files with 2219 additions and 534 deletions

No files matched your search

+147
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.
+5 -1
View File
@@ -1,3 +1,6 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: Git Town name: Git Town
on: on:
@@ -8,7 +11,7 @@ jobs:
name: Display the branch stack name: Display the branch stack
runs-on: ubuntu-slim 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: permissions:
contents: read contents: read
@@ -17,3 +20,4 @@ jobs:
steps: steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: stoatchat/action-git-town@4bc5c942e4603bffa0806b51d5fe5f0bc5deb0ac - uses: stoatchat/action-git-town@4bc5c942e4603bffa0806b51d5fe5f0bc5deb0ac
continue-on-error: true
+3
View File
@@ -1,3 +1,6 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: Release Webhook name: Release Webhook
on: on:
+4 -1
View File
@@ -1,3 +1,6 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: "Lint PR" name: "Lint PR"
on: on:
@@ -15,6 +18,6 @@ jobs:
permissions: permissions:
pull-requests: read pull-requests: read
steps: steps:
- uses: amannn/action-semantic-pull-request@v6 - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -2,4 +2,4 @@
#MISE description="Start Docker containers" #MISE description="Start Docker containers"
set -e set -e
docker compose up -d docker compose up -d "$@"
+1 -1
View File
@@ -2,4 +2,4 @@
#MISE description="Stop Docker containers" #MISE description="Stop Docker containers"
set -e set -e
docker compose down docker compose down "$@"
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.11.1" ".": "0.12.0"
} }
+65
View File
@@ -1,5 +1,70 @@
# Changelog # Changelog
## [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) ## [0.11.1](https://github.com/stoatchat/stoatchat/compare/v0.11.0...v0.11.1) (2026-02-13)
Generated
+185 -29
View File
@@ -4671,7 +4671,7 @@ dependencies = [
"log", "log",
"parking_lot", "parking_lot",
"pbjson-types", "pbjson-types",
"prost", "prost 0.12.6",
"rand 0.9.2", "rand 0.9.2",
"reqwest 0.11.27", "reqwest 0.11.27",
"scopeguard", "scopeguard",
@@ -4693,7 +4693,7 @@ dependencies = [
"parking_lot", "parking_lot",
"pbjson", "pbjson",
"pbjson-types", "pbjson-types",
"prost", "prost 0.12.6",
"prost-types", "prost-types",
"serde", "serde",
"thiserror 1.0.69", "thiserror 1.0.69",
@@ -5484,6 +5484,90 @@ dependencies = [
"vcpkg", "vcpkg",
] ]
[[package]]
name = "opentelemetry"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b84bcd6ae87133e903af7ef497404dda70c60d0ea14895fc8a5e6722754fc2a0"
dependencies = [
"futures-core",
"futures-sink",
"js-sys",
"pin-project-lite 0.2.16",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "opentelemetry-appender-tracing"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef6a1ac5ca3accf562b8c306fa8483c85f4390f768185ab775f242f7fe8fdcc2"
dependencies = [
"opentelemetry",
"tracing",
"tracing-core",
"tracing-subscriber",
]
[[package]]
name = "opentelemetry-http"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7a6d09a73194e6b66df7c8f1b680f156d916a1a942abf2de06823dd02b7855d"
dependencies = [
"async-trait",
"bytes 1.10.1",
"http 1.3.1",
"opentelemetry",
"reqwest 0.12.23",
]
[[package]]
name = "opentelemetry-otlp"
version = "0.31.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f"
dependencies = [
"http 1.3.1",
"opentelemetry",
"opentelemetry-http",
"opentelemetry-proto",
"opentelemetry_sdk",
"prost 0.14.3",
"reqwest 0.12.23",
"thiserror 2.0.18",
"tracing",
]
[[package]]
name = "opentelemetry-proto"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7175df06de5eaee9909d4805a3d07e28bb752c34cab57fa9cff549da596b30f"
dependencies = [
"opentelemetry",
"opentelemetry_sdk",
"prost 0.14.3",
"tonic",
"tonic-prost",
]
[[package]]
name = "opentelemetry_sdk"
version = "0.31.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14ae4f5991976fd48df6d843de219ca6d31b01daaab2dad5af2badeded372bd"
dependencies = [
"futures-channel",
"futures-executor",
"futures-util",
"opentelemetry",
"percent-encoding",
"rand 0.9.2",
"thiserror 2.0.18",
]
[[package]] [[package]]
name = "ordered-multimap" name = "ordered-multimap"
version = "0.4.3" version = "0.4.3"
@@ -5612,7 +5696,7 @@ checksum = "2580e33f2292d34be285c5bc3dba5259542b083cfad6037b6d70345f24dcb735"
dependencies = [ dependencies = [
"heck 0.4.1", "heck 0.4.1",
"itertools 0.11.0", "itertools 0.11.0",
"prost", "prost 0.12.6",
"prost-types", "prost-types",
] ]
@@ -5626,7 +5710,7 @@ dependencies = [
"chrono", "chrono",
"pbjson", "pbjson",
"pbjson-build", "pbjson-build",
"prost", "prost 0.12.6",
"prost-build", "prost-build",
"serde", "serde",
] ]
@@ -6209,7 +6293,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29" checksum = "deb1435c188b76130da55f17a466d252ff7b1418b2ad3e037d127b94e3411f29"
dependencies = [ dependencies = [
"bytes 1.10.1", "bytes 1.10.1",
"prost-derive", "prost-derive 0.12.6",
]
[[package]]
name = "prost"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2ea70524a2f82d518bce41317d0fae74151505651af45faf1ffbd6fd33f0568"
dependencies = [
"bytes 1.10.1",
"prost-derive 0.14.3",
] ]
[[package]] [[package]]
@@ -6226,7 +6320,7 @@ dependencies = [
"once_cell", "once_cell",
"petgraph", "petgraph",
"prettyplease", "prettyplease",
"prost", "prost 0.12.6",
"prost-types", "prost-types",
"regex", "regex",
"syn 2.0.106", "syn 2.0.106",
@@ -6246,13 +6340,26 @@ dependencies = [
"syn 2.0.106", "syn 2.0.106",
] ]
[[package]]
name = "prost-derive"
version = "0.14.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b"
dependencies = [
"anyhow",
"itertools 0.14.0",
"proc-macro2",
"quote 1.0.40",
"syn 2.0.106",
]
[[package]] [[package]]
name = "prost-types" name = "prost-types"
version = "0.12.6" version = "0.12.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0" checksum = "9091c90b0a32608e984ff2fa4091273cbdd755d54935c51d520887f4a1dbd5b0"
dependencies = [ dependencies = [
"prost", "prost 0.12.6",
] ]
[[package]] [[package]]
@@ -6660,9 +6767,9 @@ dependencies = [
[[package]] [[package]]
name = "regex" name = "regex"
version = "1.11.2" version = "1.12.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@@ -6672,9 +6779,9 @@ dependencies = [
[[package]] [[package]]
name = "regex-automata" name = "regex-automata"
version = "0.4.10" version = "0.4.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
dependencies = [ dependencies = [
"aho-corasick", "aho-corasick",
"memchr", "memchr",
@@ -6742,7 +6849,9 @@ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bytes 1.10.1", "bytes 1.10.1",
"encoding_rs", "encoding_rs",
"futures-channel",
"futures-core", "futures-core",
"futures-util",
"h2 0.4.12", "h2 0.4.12",
"http 1.3.1", "http 1.3.1",
"http-body 1.0.1", "http-body 1.0.1",
@@ -6798,7 +6907,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-autumn" name = "revolt-autumn"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"axum", "axum",
"axum-macros", "axum-macros",
@@ -6812,6 +6921,10 @@ dependencies = [
"lazy_static", "lazy_static",
"moka", "moka",
"nanoid", "nanoid",
"opentelemetry",
"opentelemetry-appender-tracing",
"opentelemetry-otlp",
"opentelemetry_sdk",
"revolt-config", "revolt-config",
"revolt-database", "revolt-database",
"revolt-files", "revolt-files",
@@ -6824,6 +6937,7 @@ dependencies = [
"simdutf8", "simdutf8",
"strum_macros", "strum_macros",
"tempfile", "tempfile",
"thumbhash",
"tokio 1.49.0", "tokio 1.49.0",
"tower-http 0.5.2", "tower-http 0.5.2",
"tracing", "tracing",
@@ -6836,7 +6950,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-bonfire" name = "revolt-bonfire"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"async-channel 2.5.0", "async-channel 2.5.0",
"async-std", "async-std",
@@ -6867,7 +6981,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-coalesced" name = "revolt-coalesced"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"indexmap 2.13.0", "indexmap 2.13.0",
"lru 0.16.3", "lru 0.16.3",
@@ -6876,7 +6990,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-config" name = "revolt-config"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"async-std", "async-std",
"cached", "cached",
@@ -6893,7 +7007,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-crond" name = "revolt-crond"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"log", "log",
"revolt-config", "revolt-config",
@@ -6905,7 +7019,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-database" name = "revolt-database"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"amqprs", "amqprs",
"async-lock 2.8.0", "async-lock 2.8.0",
@@ -6955,7 +7069,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-delta" name = "revolt-delta"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"amqprs", "amqprs",
"async-channel 1.9.0", "async-channel 1.9.0",
@@ -7005,7 +7119,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-files" name = "revolt-files"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"aes-gcm", "aes-gcm",
"anyhow", "anyhow",
@@ -7033,7 +7147,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-gifbox" name = "revolt-gifbox"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"axum", "axum",
"axum-extra", "axum-extra",
@@ -7056,7 +7170,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-january" name = "revolt-january"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"async-recursion", "async-recursion",
"axum", "axum",
@@ -7084,7 +7198,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-models" name = "revolt-models"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"indexmap 1.9.3", "indexmap 1.9.3",
"iso8601-timestamp", "iso8601-timestamp",
@@ -7103,14 +7217,14 @@ dependencies = [
[[package]] [[package]]
name = "revolt-parser" name = "revolt-parser"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"logos", "logos",
] ]
[[package]] [[package]]
name = "revolt-permissions" name = "revolt-permissions"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"async-std", "async-std",
"async-trait", "async-trait",
@@ -7125,7 +7239,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-presence" name = "revolt-presence"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"async-std", "async-std",
"log", "log",
@@ -7137,7 +7251,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-pushd" name = "revolt-pushd"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"amqprs", "amqprs",
"anyhow", "anyhow",
@@ -7149,9 +7263,12 @@ dependencies = [
"iso8601-timestamp", "iso8601-timestamp",
"log", "log",
"pretty_env_logger", "pretty_env_logger",
"redis-kiss",
"regex",
"revolt-config", "revolt-config",
"revolt-database", "revolt-database",
"revolt-models", "revolt-models",
"revolt-parser",
"revolt-presence", "revolt-presence",
"revolt-result", "revolt-result",
"revolt_a2", "revolt_a2",
@@ -7165,7 +7282,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-ratelimits" name = "revolt-ratelimits"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"authifier", "authifier",
@@ -7182,7 +7299,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-result" name = "revolt-result"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"axum", "axum",
"log", "log",
@@ -7198,7 +7315,7 @@ dependencies = [
[[package]] [[package]]
name = "revolt-voice-ingress" name = "revolt-voice-ingress"
version = "0.11.1" version = "0.12.0"
dependencies = [ dependencies = [
"amqprs", "amqprs",
"async-std", "async-std",
@@ -8832,6 +8949,12 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "thumbhash"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b7726e0245a7331bd0c9a1fb4fd99fd695bcd478ca569f0eda2ff2cb14e7a00"
[[package]] [[package]]
name = "tiff" name = "tiff"
version = "0.10.3" version = "0.10.3"
@@ -9105,6 +9228,38 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "tonic"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec"
dependencies = [
"async-trait",
"base64 0.22.1",
"bytes 1.10.1",
"http 1.3.1",
"http-body 1.0.1",
"http-body-util",
"percent-encoding",
"pin-project",
"sync_wrapper 1.0.2",
"tokio-stream",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
name = "tonic-prost"
version = "0.14.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309"
dependencies = [
"bytes 1.10.1",
"prost 0.14.3",
"tonic",
]
[[package]] [[package]]
name = "totp-lite" name = "totp-lite"
version = "2.0.1" version = "2.0.1"
@@ -9147,6 +9302,7 @@ dependencies = [
"pin-project-lite 0.2.16", "pin-project-lite 0.2.16",
"tower-layer", "tower-layer",
"tower-service", "tower-service",
"tracing",
] ]
[[package]] [[package]]
+28 -23
View File
@@ -2,14 +2,14 @@
<h1> <h1>
Stoat Backend Stoat Backend
[![Stars](https://img.shields.io/github/stars/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/stargazers) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/network/members) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/pulls) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/issues) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/graphs/contributors) [![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/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/blob/main/LICENSE) [![License](https://img.shields.io/github/license/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/blob/main/LICENSE)
</h1> </h1>
The services and libraries that power the Revolt service.<br/> The services and libraries that power the Stoat service.<br/>
<br/> <br/>
| Crate | Path | Description | | | Crate | Path | Description | |
@@ -39,7 +39,7 @@ Rust 1.86.0 or higher.
## Development Guide ## 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: Before getting started, you'll want to install:
@@ -70,8 +70,9 @@ As a heads-up, the development environment uses the following ports:
Now you can clone and build the project: Now you can clone and build the project:
```bash ```bash
git clone https://github.com/revoltchat/backend revolt-backend git clone https://github.com/stoatchat/stoatchat stoat-backend
cd revolt-backend cd stoat-backend
mise install
mise build mise build
``` ```
@@ -109,6 +110,18 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> ports: !override > ports: !override
> - "14072:5672" > - "14072:5672"
> - "14672:15672" > - "14672:15672"
>
> victoria-metrics:
> ports: !override
> - "18428:8428"
>
> victoria-logs:
> ports: !override
> - "19428:9428"
>
> victoria-traces:
> ports: !override
> - "14428:10428"
> ``` > ```
> >
> And corresponding Revolt configuration: > And corresponding Revolt configuration:
@@ -130,9 +143,6 @@ Then continue:
# start other necessary services # start other necessary services
docker compose up -d docker compose up -d
# run everything together
./scripts/start.sh
# .. or individually
# run the API server # run the API server
cargo run --bin revolt-delta cargo run --bin revolt-delta
# run the events server # run the events server
@@ -158,16 +168,11 @@ You can start a web client by doing the following:
corepack enable corepack enable
# clone the web client and run it: # clone the web client and run it:
git clone --recursive https://github.com/revoltchat/revite git clone --recursive https://github.com/stoatchat/for-web stoat-web
cd revite cd stoat-web
yarn # refer to stoat-web/README.md for startup, creating an account and loging in
yarn build:deps
echo "VITE_API_URL=http://local.revolt.chat:14702" > .env.local
yarn dev --port 14701
``` ```
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. When signing up, go to http://localhost:14080 to find confirmation/password reset emails.
## Deployment Guide ## Deployment Guide
@@ -198,7 +203,7 @@ Tag and push a new release by running:
just release 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 ## Testing
@@ -217,6 +222,6 @@ TEST_DB=MONGODB cargo nextest run
## License ## 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!** **Individual crates may supply their own licenses!**
+1 -1
View File
@@ -15,7 +15,7 @@ host = "127.0.0.1"
[hosts] [hosts]
# Web locations of various services # Web locations of various services
# Defaults assume all services are reverse-proxied # 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! # Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat:14701" app = "http://local.revolt.chat:14701"
+23 -1
View File
@@ -1,3 +1,5 @@
name: stoat-backend
services: services:
# Redis # Redis
redis: redis:
@@ -69,9 +71,29 @@ services:
MAILDEV_INCOMING_USER: smtp MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp MAILDEV_INCOMING_PASS: smtp
# LiveKit
livekit: livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.9 image: ghcr.io/stoatchat/livekit-server:v1.9.13
command: --config /etc/livekit.yml command: --config /etc/livekit.yml
network_mode: "host" network_mode: "host"
volumes: volumes:
- ./livekit.yml:/etc/livekit.yml - ./livekit.yml:/etc/livekit.yml
# VM
victoria-metrics:
image: victoriametrics/victoria-metrics:latest
ports:
- 8428:8428
# VL
victoria-logs:
image: docker.io/victoriametrics/victoria-logs:v1.43.1
ports:
- 9428:9428
command: -storageDataPath=victoria-logs-data
# VT
victoria-traces:
image: docker.io/victoriametrics/victoria-traces:latest
ports:
- 10428:10428
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-bonfire" name = "revolt-bonfire"
version = "0.11.1" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
publish = false publish = false
+12 -3
View File
@@ -4,7 +4,7 @@ use futures::future::join_all;
use revolt_database::{ use revolt_database::{
events::client::{EventV1, ReadyPayloadFields}, events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery, util::permissions::DatabasePermissionQuery,
voice::get_channel_voice_state, voice::{get_channel_voice_state, UserVoiceChannel},
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus, Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
}; };
use revolt_models::v0; use revolt_models::v0;
@@ -167,9 +167,13 @@ impl State {
| Channel::TextChannel { voice: Some(_), .. } | 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() { 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 { for participant in &voice_state.participants {
user_ids.insert(participant.id.clone()); user_ids.insert(participant.id.clone());
@@ -397,6 +401,11 @@ impl State {
/// Push presence change to the user and all associated server topics /// Push presence change to the user and all associated server topics
pub async fn broadcast_presence_change(&self, target: bool) { 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 { if if let Some(status) = &self.cache.users.get(&self.cache.user_id).unwrap().status {
status.presence != Some(Presence::Invisible) status.presence != Some(Presence::Invisible)
} else { } else {
+17 -1
View File
@@ -218,7 +218,13 @@ async fn listener(
kill_signal_r: async_channel::Receiver<()>, kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>, write: &Mutex<WsWriter>,
) { ) {
let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap(); 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 subscriber = match report_internal_error!( let subscriber = match report_internal_error!(
fred::types::Builder::from_config(redis_config).build_subscriber_client() fred::types::Builder::from_config(redis_config).build_subscriber_client()
) { ) {
@@ -422,6 +428,8 @@ async fn worker(
mut read: WsReader, mut read: WsReader,
write: &Mutex<WsWriter>, write: &Mutex<WsWriter>,
) { ) {
let revolt_config = revolt_config::config().await;
loop { loop {
let t1 = read.try_next().fuse(); let t1 = read.try_next().fuse();
let t2 = kill_signal_r.recv().fuse(); let t2 = kill_signal_r.recv().fuse();
@@ -458,6 +466,10 @@ async fn worker(
match payload { match payload {
ClientMessage::BeginTyping { channel } => { ClientMessage::BeginTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) { if !subscribed.read().await.contains(&channel) {
continue; continue;
} }
@@ -470,6 +482,10 @@ async fn worker(
.await; .await;
} }
ClientMessage::EndTyping { channel } => { ClientMessage::EndTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) { if !subscribed.read().await.contains(&channel) {
continue; continue;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-coalesced" name = "revolt-coalesced"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"] authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"]
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-config" name = "revolt-config"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -38,4 +38,4 @@ sentry = { version = "0.31.5", optional = true }
sentry-anyhow = { version = "0.38.1", optional = true } sentry-anyhow = { version = "0.38.1", optional = true }
# Core # Core
revolt-result = { version = "0.11.1", path = "../result", optional = true } revolt-result = { version = "0.12.0", path = "../result", optional = true }
+15 -5
View File
@@ -1,4 +1,5 @@
production = false production = false
disable_events_dont_use = false
[database] [database]
# MongoDB connection URL # MongoDB connection URL
@@ -11,7 +12,7 @@ redis = "redis://redis/"
[hosts] [hosts]
# Web locations of various services # Web locations of various services
# Defaults assume all services are reverse-proxied # 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! # Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat" app = "http://local.revolt.chat"
@@ -34,7 +35,7 @@ password = "rabbitpass"
[api.registration] [api.registration]
# Whether an invite should be required for 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 invite_only = false
[api.smtp] [api.smtp]
@@ -88,6 +89,11 @@ production = true
# Increasing this will resolve mentions faster, but will consume more memory while resolving. # Increasing this will resolve mentions faster, but will consume more memory while resolving.
mass_mention_chunk_size = 200 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 # none of these should need changing
exchange = "revolt.notifications" exchange = "revolt.notifications"
message_queue = "notifications.origin.message" message_queue = "notifications.origin.message"
@@ -220,6 +226,10 @@ new_user_hours = 72
# (should be greater than any one file upload limit) # (should be greater than any one file upload limit)
body_limit_size = 20_000_000 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] [features.limits.new_user]
# Limits imposed on new users # Limits imposed on new users
@@ -244,7 +254,7 @@ voice_quality = 16000
# Whether the user can use video streams in voice calls # Whether the user can use video streams in voice calls
video = true 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] video_resolution = [1080, 720]
# Minimum and maximum aspect ratio of video streams in voice calls # Minimum and maximum aspect ratio of video streams in voice calls
@@ -283,8 +293,8 @@ voice_quality = 16000
# Whether the user can use video streams in voice calls # Whether the user can use video streams in voice calls
video = true 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] video_resolution = [1280, 720]
# Minimum and maximum aspect ratio of video streams in voice calls # Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5] video_aspect_ratio = [0.3, 2.5]
+12 -4
View File
@@ -1,7 +1,7 @@
use std::{collections::HashMap, path::Path}; use std::{collections::HashMap, path::Path};
use cached::proc_macro::cached; use cached::proc_macro::cached;
use config::{Config, File, FileFormat}; use config::{Config, Environment, File, FileFormat};
use futures_locks::RwLock; use futures_locks::RwLock;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::Deserialize; use serde::Deserialize;
@@ -109,6 +109,8 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
cwd = path.parent(); cwd = path.parent();
} }
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap() builder.build().unwrap()
}) })
}); });
@@ -117,6 +119,7 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
pub struct Database { pub struct Database {
pub mongodb: String, pub mongodb: String,
pub redis: String, pub redis: String,
pub redis_pubsub: Option<String>,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -245,6 +248,7 @@ pub struct Pushd {
pub production: bool, pub production: bool,
pub exchange: String, pub exchange: String,
pub mass_mention_chunk_size: usize, pub mass_mention_chunk_size: usize,
pub render_cache_time: usize,
// Queues // Queues
pub message_queue: String, pub message_queue: String,
@@ -341,6 +345,8 @@ pub struct GlobalLimits {
pub new_user_hours: usize, pub new_user_hours: usize,
pub body_limit_size: usize, pub body_limit_size: usize,
pub restrict_server_creation: Vec<String>,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -418,6 +424,7 @@ pub struct Settings {
pub features: Features, pub features: Features,
pub sentry: Sentry, pub sentry: Sentry,
pub production: bool, pub production: bool,
pub disable_events_dont_use: bool,
} }
impl Settings { impl Settings {
@@ -448,12 +455,14 @@ pub async fn config() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap(); let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library // 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()); std::env::set_var("REDIS_URI", config.database.redis.clone());
} }
// auto-detect production nodes // 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; config.production = true;
} }
@@ -471,7 +480,6 @@ pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry:
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0"); std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
} }
pretty_env_logger::init();
log::info!("Starting {release}"); log::info!("Starting {release}");
if dsn.is_empty() { if dsn.is_empty() {
+7 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-database" name = "revolt-database"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -32,19 +32,19 @@ default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies] [dependencies]
# Core # Core
revolt-config = { version = "0.11.1", path = "../config", features = [ revolt-config = { version = "0.12.0", path = "../config", features = [
"report-macros", "report-macros",
] } ] }
revolt-result = { version = "0.11.1", path = "../result" } revolt-result = { version = "0.12.0", path = "../result" }
revolt-models = { version = "0.11.1", path = "../models", features = [ revolt-models = { version = "0.12.0", path = "../models", features = [
"validator", "validator",
] } ] }
revolt-presence = { version = "0.11.1", path = "../presence" } revolt-presence = { version = "0.12.0", path = "../presence" }
revolt-permissions = { version = "0.11.1", path = "../permissions", features = [ revolt-permissions = { version = "0.12.0", path = "../permissions", features = [
"serde", "serde",
"bson", "bson",
] } ] }
revolt-parser = { version = "0.11.1", path = "../parser" } revolt-parser = { version = "0.12.0", path = "../parser" }
# Utility # Utility
log = "0.4" log = "0.4"
@@ -110,6 +110,10 @@ auto_derived!(
/// Voice Information for when this channel is also a voice channel /// Voice Information for when this channel is also a voice channel
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
voice: Option<VoiceInformation>, voice: Option<VoiceInformation>,
/// The channel's slowmode delay in seconds
#[serde(skip_serializing_if = "Option::is_none")]
slowmode: Option<u64>,
}, },
} }
@@ -146,6 +150,8 @@ auto_derived!(
pub last_message_id: Option<String>, pub last_message_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<VoiceInformation>, pub voice: Option<VoiceInformation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slowmode: Option<u64>,
} }
/// Optional fields on channel object /// Optional fields on channel object
@@ -206,6 +212,7 @@ impl Channel {
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()), voice: data.voice.map(|voice| voice.into()),
slowmode: None
}, },
v0::LegacyServerChannelType::Voice => Channel::TextChannel { v0::LegacyServerChannelType::Voice => Channel::TextChannel {
id: id.clone(), id: id.clone(),
@@ -218,6 +225,7 @@ impl Channel {
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()), voice: Some(data.voice.unwrap_or_default().into()),
slowmode: None
}, },
}; };
@@ -45,7 +45,9 @@ auto_derived!(
Image { Image {
width: isize, width: isize,
height: isize, height: isize,
// animated: bool // TODO: https://docs.rs/image/latest/image/trait.AnimationDecoder.html for APNG support thumbhash: Option<Vec<u8>>,
#[serde(default)]
animated: bool,
}, },
/// File is a video with specific dimensions /// File is a video with specific dimensions
Video { width: isize, height: isize }, Video { width: isize, height: isize },
@@ -1,5 +1,3 @@
use std::{collections::HashSet, hash::RandomState};
use indexmap::{IndexMap, IndexSet}; use indexmap::{IndexMap, IndexSet};
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
use revolt_config::{config, FeaturesLimits}; use revolt_config::{config, FeaturesLimits};
@@ -9,6 +7,8 @@ use revolt_models::v0::{
}; };
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
use revolt_result::{ErrorType, Result}; use revolt_result::{ErrorType, Result};
use std::time::SystemTime;
use std::{collections::HashSet, hash::RandomState};
use ulid::Ulid; use ulid::Ulid;
use validator::Validate; use validator::Validate;
@@ -388,6 +388,7 @@ impl Message {
mut role_mentions, mut role_mentions,
mut mentions_everyone, mut mentions_everyone,
mut mentions_online, mut mentions_online,
..
} = message_mentions; } = message_mentions;
if allow_mass_mentions && server_id.is_some() && !role_mentions.is_empty() { if allow_mass_mentions && server_id.is_some() && !role_mentions.is_empty() {
@@ -687,8 +688,13 @@ impl Message {
) )
.await?; .await?;
let is_dm_or_group = matches!(
channel,
Channel::DirectMessage { .. } | Channel::Group { .. }
);
if !self.has_suppressed_notifications() 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 // send Push notifications
#[cfg(feature = "tasks")] #[cfg(feature = "tasks")]
@@ -699,7 +705,7 @@ impl Message {
Some( Some(
PushNotification::from( PushNotification::from(
self.clone().into_model(user, member), self.clone().into_model(user, member),
Some(author), Some(author.clone()),
channel.to_owned().into(), channel.to_owned().into(),
) )
.await, .await,
@@ -707,7 +713,11 @@ impl Message {
self.clone(), self.clone(),
match channel { match channel {
Channel::DirectMessage { recipients, .. } Channel::DirectMessage { recipients, .. }
| Channel::Group { recipients, .. } => recipients.clone(), | Channel::Group { recipients, .. } => recipients
.iter()
.filter(|uid| *uid != author.id())
.cloned()
.collect(),
Channel::TextChannel { .. } => { Channel::TextChannel { .. } => {
self.mentions.clone().unwrap_or_default() self.mentions.clone().unwrap_or_default()
} }
@@ -1029,6 +1039,31 @@ impl Message {
Ok(()) 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 /// Remove a reaction from a message
pub async fn remove_reaction(&self, db: &Database, user: &str, emoji: &str) -> Result<()> { pub async fn remove_reaction(&self, db: &Database, user: &str, emoji: &str) -> Result<()> {
// Check if it actually exists // Check if it actually exists
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use std::time::SystemTime;
use revolt_result::Result; use revolt_result::Result;
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage}; 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 /// Delete messages from a channel by their ids and corresponding channel id
async fn delete_messages(&self, channel: &str, ids: &[String]) -> Result<()>; 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>>>;
} }
@@ -1,8 +1,12 @@
use bson::{to_bson, Document}; use bson::{to_bson, Document};
use futures::try_join; use futures::try_join;
use futures::StreamExt;
use mongodb::options::FindOptions; use mongodb::options::FindOptions;
use revolt_models::v0::MessageSort; use revolt_models::v0::MessageSort;
use revolt_result::Result; use revolt_result::Result;
use std::collections::{HashMap, HashSet};
use std::time::SystemTime;
use ulid::Ulid;
use crate::{ use crate::{
AppendMessage, DocumentId, FieldsMessage, IntoDocumentPath, Message, MessageQuery, AppendMessage, DocumentId, FieldsMessage, IntoDocumentPath, Message, MessageQuery,
@@ -306,6 +310,112 @@ impl AbstractMessages for MongoDb {
.map(|_| ()) .map(|_| ())
.map_err(|_| create_database_error!("delete_many", COL)) .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 { impl IntoDocumentPath for FieldsMessage {
@@ -1,7 +1,9 @@
use std::collections::HashMap;
use futures::future::try_join_all; use futures::future::try_join_all;
use indexmap::IndexSet; use indexmap::IndexSet;
use revolt_result::Result; use revolt_result::Result;
use std::time::SystemTime;
use ulid::Ulid;
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb}; use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb};
use super::AbstractMessages; use super::AbstractMessages;
@@ -286,4 +288,63 @@ impl AbstractMessages for ReferenceDb {
Ok(()) 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)
}
} }
@@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use crate::{ use crate::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File,
Database, File, Server, SystemMessage, User, Server, SystemMessage, User,
}; };
fn default_true() -> bool { fn default_true() -> bool {
@@ -45,7 +45,6 @@ auto_derived_partial!(
/// Whether the member is server-wide voice deafened /// Whether the member is server-wide voice deafened
#[serde(skip_serializing_if = "is_true", default = "default_true")] #[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_receive: bool, pub can_receive: bool,
// This value only exists in the database, not the models. // 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. // If it is not-None, the database layer should return None to member fetching queries.
// pub pending_deletion_at: Option<Timestamp> // pub pending_deletion_at: Option<Timestamp>
@@ -153,7 +152,11 @@ impl Member {
#[cfg(feature = "voice")] #[cfg(feature = "voice")]
for channel in &channels { 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) voice_states.push(voice_state)
} }
} }
@@ -175,7 +178,7 @@ impl Member {
.map(|channel| channel.into()) .map(|channel| channel.into())
.collect(), .collect(),
emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(), emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(),
voice_states voice_states,
} }
.private(user.id.clone()) .private(user.id.clone())
.await; .await;
@@ -225,14 +228,14 @@ impl Member {
pub fn remove_field(&mut self, field: &FieldsMember) { pub fn remove_field(&mut self, field: &FieldsMember) {
match field { match field {
FieldsMember::JoinedAt => {}, FieldsMember::JoinedAt => {}
FieldsMember::Avatar => self.avatar = None, FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None, FieldsMember::Nickname => self.nickname = None,
FieldsMember::Roles => self.roles.clear(), FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None, FieldsMember::Timeout => self.timeout = None,
FieldsMember::CanReceive => self.can_receive = true, FieldsMember::CanReceive => self.can_receive = true,
FieldsMember::CanPublish => self.can_publish = true, FieldsMember::CanPublish => self.can_publish = true,
FieldsMember::VoiceChannel => {}, FieldsMember::VoiceChannel => {}
} }
} }
+29 -7
View File
@@ -190,6 +190,7 @@ impl From<crate::Channel> for Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice, voice,
slowmode
} => Channel::TextChannel { } => Channel::TextChannel {
id, id,
server, server,
@@ -201,6 +202,7 @@ impl From<crate::Channel> for Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice: voice.map(|voice| voice.into()), voice: voice.map(|voice| voice.into()),
slowmode
}, },
} }
} }
@@ -254,6 +256,7 @@ impl From<Channel> for crate::Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice, voice,
slowmode
} => crate::Channel::TextChannel { } => crate::Channel::TextChannel {
id, id,
server, server,
@@ -265,6 +268,7 @@ impl From<Channel> for crate::Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice: voice.map(|voice| voice.into()), voice: voice.map(|voice| voice.into()),
slowmode
}, },
} }
} }
@@ -283,7 +287,8 @@ impl From<crate::PartialChannel> for PartialChannel {
role_permissions: value.role_permissions, role_permissions: value.role_permissions,
default_permissions: value.default_permissions, default_permissions: value.default_permissions,
last_message_id: value.last_message_id, 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, role_permissions: value.role_permissions,
default_permissions: value.default_permissions, default_permissions: value.default_permissions,
last_message_id: value.last_message_id, 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 { match value {
crate::Metadata::File => Metadata::File, crate::Metadata::File => Metadata::File,
crate::Metadata::Text => Metadata::Text, 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, width: width as usize,
height: height as usize, height: height as usize,
thumbhash,
animated,
}, },
crate::Metadata::Video { width, height } => Metadata::Video { crate::Metadata::Video { width, height } => Metadata::Video {
width: width as usize, width: width as usize,
@@ -424,9 +437,16 @@ impl From<Metadata> for crate::Metadata {
match value { match value {
Metadata::File => crate::Metadata::File, Metadata::File => crate::Metadata::File,
Metadata::Text => crate::Metadata::Text, 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, width: width as isize,
height: height as isize, height: height as isize,
thumbhash,
animated,
}, },
Metadata::Video { width, height } => crate::Metadata::Video { Metadata::Video { width, height } => crate::Metadata::Video {
width: width as isize, 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::UserRemove { id, by } => Self::UserRemove { id, by },
crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by }, crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by },
crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { 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 }
}
} }
} }
} }
@@ -1382,7 +1404,7 @@ impl From<FieldsMessage> for crate::FieldsMessage {
impl From<VoiceInformation> for crate::VoiceInformation { impl From<VoiceInformation> for crate::VoiceInformation {
fn from(value: VoiceInformation) -> Self { fn from(value: VoiceInformation) -> Self {
crate::VoiceInformation { crate::VoiceInformation {
max_users: value.max_users max_users: value.max_users,
} }
} }
} }
@@ -1390,7 +1412,7 @@ impl From<VoiceInformation> for crate::VoiceInformation {
impl From<crate::VoiceInformation> for VoiceInformation { impl From<crate::VoiceInformation> for VoiceInformation {
fn from(value: crate::VoiceInformation) -> Self { fn from(value: crate::VoiceInformation) -> Self {
VoiceInformation { VoiceInformation {
max_users: value.max_users max_users: value.max_users,
} }
} }
} }
+173 -77
View File
@@ -1,3 +1,5 @@
use std::fmt::{Display, Write};
use crate::{ use crate::{
events::client::EventV1, events::client::EventV1,
models::{Channel, User}, models::{Channel, User},
@@ -6,7 +8,11 @@ use crate::{
}; };
use iso8601_timestamp::{Duration, Timestamp}; use iso8601_timestamp::{Duration, Timestamp};
use livekit_protocol::ParticipantPermission; 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_config::FeaturesLimits;
use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState}; use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
@@ -16,16 +22,18 @@ mod voice_client;
pub use voice_client::VoiceClient; pub use voice_client::VoiceClient;
async fn get_connection() -> Result<Conn> { 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?; let mut conn = get_connection().await?;
if user.bot.is_some() { 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 // 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 if conn
.sismember(format!("vc:{}", &user.id), channel_id) .sismember(format!("vc:{}", &user.id), channel)
.await .await
.to_internal_error()? .to_internal_error()?
{ {
@@ -43,23 +51,31 @@ pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> {
Ok(()) 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() get_connection()
.await? .await?
.set(format!("node:{channel}"), node) .set(format!("node:{channel_id}"), node)
.await .await
.to_internal_error() .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() get_connection()
.await? .await?
.get(format!("node:{channel}")) .get(format!("node:{channel_id}"))
.await .await
.to_internal_error() .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() get_connection()
.await? .await?
.smembers(format!("vc:{user_id}")) .smembers(format!("vc:{user_id}"))
@@ -68,14 +84,14 @@ pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<String>> {
} }
pub async fn set_user_moved_from_voice( pub async fn set_user_moved_from_voice(
old_channel: &str, old_channel_id: &str,
new_channel: &str, new_channel: &UserVoiceChannel,
user_id: &str, user_id: &str,
) -> Result<()> { ) -> Result<()> {
get_connection() get_connection()
.await? .await?
.set_ex( .set_ex(
format!("moved_from:{user_id}:{old_channel}"), format!("moved_from:{user_id}:{old_channel_id}"),
new_channel, new_channel,
10, 10,
) )
@@ -92,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( pub async fn set_user_moved_to_voice(
new_channel: &str, new_channel_id: &str,
old_channel: &str, old_channel: &UserVoiceChannel,
user_id: &str, user_id: &str,
) -> Result<()> { ) -> Result<()> {
get_connection() get_connection()
.await? .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 .await
.to_internal_error() .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() get_connection()
.await? .await?
.get_del(format!("moved_to:{user_id}:{channel_id}")) .get_del(format!("moved_to:{user_id}:{channel_id}"))
@@ -111,10 +134,10 @@ pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result<
.to_internal_error() .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() get_connection()
.await? .await?
.sismember(format!("vc:{user_id}"), channel_id) .sismember(format!("vc:{user_id}"), channel)
.await .await
.to_internal_error() .to_internal_error()
} }
@@ -148,12 +171,15 @@ pub fn get_allowed_sources(
} }
pub async fn create_voice_state( pub async fn create_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
joined_at: Timestamp, joined_at: Timestamp,
) -> Result<UserVoiceState> { ) -> 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 { let voice_state = UserVoiceState {
joined_at, joined_at,
@@ -165,9 +191,9 @@ pub async fn create_voice_state(
}; };
Pipeline::new() Pipeline::new()
.sadd(format!("vc_members:{channel_id}"), user_id) .sadd(format!("vc_members:{}", &channel.id), user_id)
.sadd(format!("vc:{user_id}"), channel_id) .sadd(format!("vc:{user_id}"), channel)
.set(&unique_key, channel_id) .set(&unique_key, &channel.id)
.set( .set(
format!("joined_at:{unique_key}"), format!("joined_at:{unique_key}"),
joined_at joined_at
@@ -194,16 +220,16 @@ pub async fn create_voice_state(
Ok(voice_state) Ok(voice_state)
} }
pub async fn delete_voice_state( pub async fn delete_voice_state(channel: &UserVoiceChannel, user_id: &str) -> Result<()> {
channel_id: &str, let unique_key = format!(
server_id: Option<&str>, "{}:{}",
user_id: &str, &user_id,
) -> Result<()> { channel.server_id.as_ref().unwrap_or(&channel.id)
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); );
Pipeline::new() Pipeline::new()
.srem(format!("vc_members:{channel_id}"), user_id) .srem(format!("vc_members:{}", &channel.id), user_id)
.srem(format!("vc:{user_id}"), channel_id) .srem(format!("vc:{user_id}"), channel)
.del(&[ .del(&[
format!("joined_at:{unique_key}"), format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"), format!("is_publishing:{unique_key}"),
@@ -218,19 +244,19 @@ pub async fn delete_voice_state(
} }
pub async fn delete_channel_voice_state( pub async fn delete_channel_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_ids: &[String], user_ids: &[String],
) -> Result<()> { ) -> 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(); 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 { for user_id in user_ids {
let unique_key = format!("{user_id}:{parent_id}"); 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!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"), format!("is_publishing:{unique_key}"),
format!("is_receiving:{unique_key}"), format!("is_receiving:{unique_key}"),
@@ -247,8 +273,7 @@ pub async fn delete_channel_voice_state(
} }
pub async fn update_voice_state_tracks( pub async fn update_voice_state_tracks(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
added: bool, added: bool,
track: i32, track: i32,
@@ -273,18 +298,21 @@ pub async fn update_voice_state_tracks(
_ => unreachable!(), _ => unreachable!(),
}; };
update_voice_state(channel_id, server_id, user_id, &partial).await?; update_voice_state(channel, user_id, &partial).await?;
Ok(partial) Ok(partial)
} }
pub async fn update_voice_state( pub async fn update_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
partial: &PartialUserVoiceState, partial: &PartialUserVoiceState,
) -> Result<()> { ) -> 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(); let mut pipeline = Pipeline::new();
@@ -310,21 +338,24 @@ pub async fn update_voice_state(
.to_internal_error() .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() get_connection()
.await? .await?
.smembers::<_, Option<Vec<String>>>(format!("vc_members:{channel_id}")) .smembers::<_, Option<Vec<String>>>(format!("vc_members:{}", &channel.id))
.await .await
.to_internal_error() .to_internal_error()
.map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) })) .map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) }))
} }
pub async fn get_voice_state( pub async fn get_voice_state(
channel_id: &str, channel: &UserVoiceChannel,
server_id: Option<&str>,
user_id: &str, user_id: &str,
) -> Result<Option<UserVoiceState>> { ) -> 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() let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection()
.await? .await?
@@ -365,21 +396,21 @@ pub async fn get_voice_state(
} }
} }
pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::ChannelVoiceState>> { pub async fn get_channel_voice_state(
let members = get_voice_channel_members(channel.id()).await?; channel: &UserVoiceChannel,
) -> Result<Option<v0::ChannelVoiceState>> {
let server = channel.server(); let members = get_voice_channel_members(channel).await?;
if let Some(members) = members { if let Some(members) = members {
let mut participants = Vec::with_capacity(members.len()); let mut participants = Vec::with_capacity(members.len());
for user_id in members { 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); participants.push(voice_state);
} else { } else {
log::info!("Voice state not found but member in voice channel members, removing."); 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?;
} }
} }
@@ -387,7 +418,7 @@ pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::Cha
participants.shrink_to_fit(); participants.shrink_to_fit();
Ok(Some(v0::ChannelVoiceState { Ok(Some(v0::ChannelVoiceState {
id: channel.id().to_string(), id: channel.id.clone(),
participants, participants,
})) }))
} else { } else {
@@ -395,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() get_connection()
.await? .await?
.smove( .smove(
format!("vc-members-{from}"), format!("vc_members:{from_channel_id}"),
format!("vc-members-{to}"), format!("vc_members:{to_channel_id}"),
user, user,
) )
.await .await
@@ -414,11 +445,13 @@ pub async fn sync_voice_permissions(
server: Option<&Server>, server: Option<&Server>,
role_id: Option<&str>, role_id: Option<&str>,
) -> Result<()> { ) -> Result<()> {
let user_voice_channel = UserVoiceChannel::from_channel(channel);
let Some(node) = get_channel_node(channel.id()).await? else { let Some(node) = get_channel_node(channel.id()).await? else {
return Ok(()); return Ok(());
}; };
for user_id in get_voice_channel_members(channel.id()) for user_id in get_voice_channel_members(&user_voice_channel)
.await? .await?
.iter() .iter()
.flatten() .flatten()
@@ -458,7 +491,9 @@ pub async fn sync_user_voice_permissions(
.as_ref() .as_ref()
.is_none_or(|member| member.roles.iter().any(|r| r == role_id)) .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(()); return Ok(());
}; };
@@ -489,7 +524,7 @@ pub async fn sync_user_voice_permissions(
update_event.screensharing = voice_state.screensharing.then_some(can_video); update_event.screensharing = voice_state.screensharing.then_some(can_video);
update_event.is_publishing = voice_state.is_publishing.then_some(can_speak); 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 voice_client
.update_permissions( .update_permissions(
@@ -567,34 +602,95 @@ pub async fn get_call_notification_recipients(
.to_internal_error() .to_internal_error()
} }
pub async fn remove_user_from_voice_channels(db: &Database, voice_client: &VoiceClient, user_id: &str) -> Result<()> { pub async fn remove_user_from_voice_channels(
for channel_id in get_user_voice_channels(user_id).await? { voice_client: &VoiceClient,
remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?; 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?;
}
Ok(())
}
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: &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?;
delete_channel_voice_state(channel, &users).await?;
}; };
Ok(()) Ok(())
} }
pub async fn remove_user_from_voice_channel(db: &Database, voice_client: &VoiceClient, channel_id: &str, user_id: &str) -> Result<()> { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
if let Some(node) = get_channel_node(channel_id).await? { pub struct RoomMetadata {
let _ = voice_client.remove_user(&node, user_id, channel_id).await; pub server: Option<String>,
} }
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; #[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserVoiceChannel {
delete_voice_state(channel_id, channel.server(), user_id).await?; pub id: String,
pub server_id: Option<String>,
Ok(())
} }
pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, server_id: Option<&str>) -> Result<()> { impl UserVoiceChannel {
if let Some(users) = get_voice_channel_members(channel_id).await? { pub fn from_string(input: String) -> Self {
let node = get_channel_node(channel_id).await?.unwrap(); let mut parts = input.splitn(2, '-');
voice_client.delete_room(&node, channel_id).await?; Self {
id: parts.next().unwrap().to_string(),
server_id: parts.next().map(ToString::to_string),
}
}
delete_channel_voice_state(channel_id, server_id, &users).await?; 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(()) 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)
}
}
@@ -1,5 +1,6 @@
use crate::{ use crate::{
models::{Channel, User}, models::{Channel, User},
voice::RoomMetadata,
Database, Database,
}; };
use livekit_api::{ use livekit_api::{
@@ -102,11 +103,16 @@ impl VoiceClient {
pub async fn create_room(&self, node: &str, channel: &Channel) -> Result<Room> { pub async fn create_room(&self, node: &str, channel: &Channel) -> Result<Room> {
let room = self.get_node(node)?; let room = self.get_node(node)?;
let metadata = RoomMetadata {
server: channel.server().map(|id| id.to_string()),
};
room.client room.client
.create_room( .create_room(
channel.id(), channel.id(),
CreateRoomOptions { CreateRoomOptions {
empty_timeout: 5 * 60, // 5 minutes, empty_timeout: 5 * 60, // 5 minutes,
metadata: serde_json::to_string(&metadata).to_internal_error()?,
..Default::default() ..Default::default()
}, },
) )
@@ -6,4 +6,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd. This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here: Learn more about third party instances here:
https://developers.stoat.chat/faq.html https://developers.stoat.chat/faq/
@@ -6,4 +6,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd. This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here: Learn more about third party instances here:
https://developers.revolt.chat/faq.html https://developers.stoat.chat/faq/
@@ -107,7 +107,7 @@
<p class="" style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left"> <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 Further violations may result in a permanent ban depending on
severity, please abide by the 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> </p>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%"> <table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody> <tbody>
@@ -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. 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. Ban evasion is prohibited and will be dealt with accordingly.
@@ -3,6 +3,6 @@ Your account has been suspended, for one or more reasons:
This email is intended for {{email}} 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: Learn more about third party instances here:
https://developers.revolt.chat/faq.html https://developers.stoat.chat/faq/
@@ -7,4 +7,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd. This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here: Learn more about third party instances here:
https://developers.revolt.chat/faq.html https://developers.stoat.chat/faq/
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-files" name = "revolt-files"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -27,10 +27,10 @@ typenum = "1.17.0"
aws-config = "1.5.5" aws-config = "1.5.5"
aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] } aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
revolt-config = { version = "0.11.1", path = "../config", features = [ revolt-config = { version = "0.12.0", path = "../config", features = [
"report-macros", "report-macros",
] } ] }
revolt-result = { version = "0.11.1", path = "../result" } revolt-result = { version = "0.12.0", path = "../result" }
# image processing # image processing
jxl-oxide = { workspace = true } jxl-oxide = { workspace = true }
@@ -1,5 +1,5 @@
use anyhow::Result; use anyhow::Result;
use image::{DynamicImage, ImageBuffer, ImageReader}; use image::{AnimationDecoder, DynamicImage, ImageBuffer, ImageReader};
use jxl_oxide::integration::JxlDecoder; use jxl_oxide::integration::JxlDecoder;
use revolt_config::report_internal_error; use revolt_config::report_internal_error;
use std::io::{BufRead, Read, Seek}; 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)> { fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)> {
match mime { match mime {
"image/svg+xml" => { "image/svg+xml" => {
@@ -171,9 +196,9 @@ impl MediaRepository for MediaImpl {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::io::Cursor;
use crate::{MediaImpl, MediaRepository}; use crate::{MediaImpl, MediaRepository};
use std::io::{Cursor, Write};
use tempfile::NamedTempFile;
#[tokio::test] #[tokio::test]
async fn asset_test_jpeg() { async fn asset_test_jpeg() {
@@ -186,6 +211,15 @@ mod tests {
media.create_thumbnail(image, "attachments"); 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] #[tokio::test]
async fn asset_test_jpeg_extra_bytes() { async fn asset_test_jpeg_extra_bytes() {
let media = MediaImpl::from_config().await; let media = MediaImpl::from_config().await;
@@ -212,6 +246,15 @@ mod tests {
media.create_thumbnail(image, "emojis"); 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] #[tokio::test]
async fn asset_test_png_extra_bytes() { async fn asset_test_png_extra_bytes() {
let media = MediaImpl::from_config().await; let media = MediaImpl::from_config().await;
@@ -259,6 +302,15 @@ mod tests {
media.create_thumbnail(image, "attachments"); 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] #[tokio::test]
async fn asset_test_jxl() { async fn asset_test_jxl() {
let media = MediaImpl::from_config().await; let media = MediaImpl::from_config().await;
@@ -292,6 +344,15 @@ mod tests {
media.create_thumbnail(image, "attachments"); 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] #[tokio::test]
async fn asset_test_animated_webp() { async fn asset_test_animated_webp() {
let media = MediaImpl::from_config().await; let media = MediaImpl::from_config().await;
@@ -303,6 +364,15 @@ mod tests {
media.create_thumbnail(image, "attachments"); 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] #[tokio::test]
async fn asset_test_animated_gif() { async fn asset_test_animated_gif() {
let media = MediaImpl::from_config().await; let media = MediaImpl::from_config().await;
@@ -313,4 +383,13 @@ mod tests {
let image = media.decode_image(&mut reader, "image/gif").unwrap(); let image = media.decode_image(&mut reader, "image/gif").unwrap();
media.create_thumbnail(image, "attachments"); 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));
}
} }
+28
View File
@@ -91,6 +91,34 @@ pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> {
media.image_size_vec(v, mime) 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 /// Determine size of video at temp file
pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> { pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> {
let media = MediaImpl::new(Files { let media = MediaImpl::new(Files {
@@ -6,6 +6,9 @@ use thiserror::Error;
pub trait MediaRepository: Send + Sync + 'static { pub trait MediaRepository: Send + Sync + 'static {
fn image_size(&self, f: &NamedTempFile) -> Option<(usize, usize)>; 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 image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)>;
fn decode_image<R: Read + BufRead + Seek>( fn decode_image<R: Read + BufRead + Seek>(
+3 -3
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-models" name = "revolt-models"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -21,8 +21,8 @@ default = ["serde", "partials", "rocket"]
[dependencies] [dependencies]
# Core # Core
revolt-config = { version = "0.11.1", path = "../config" } revolt-config = { version = "0.12.0", path = "../config" }
revolt-permissions = { version = "0.11.1", path = "../permissions" } revolt-permissions = { version = "0.12.0", path = "../permissions" }
# Utility # Utility
regex = "1.11" regex = "1.11"
+10
View File
@@ -112,6 +112,10 @@ auto_derived!(
/// Voice Information for when this channel is also a voice channel /// Voice Information for when this channel is also a voice channel
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
voice: Option<VoiceInformation>, 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>, pub last_message_id: Option<String>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub voice: Option<VoiceInformation>, pub voice: Option<VoiceInformation>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub slowmode: Option<u64>,
} }
/// Optional fields on channel object /// Optional fields on channel object
@@ -189,6 +195,10 @@ auto_derived!(
/// Voice Information for voice channels /// Voice Information for voice channels
pub voice: Option<VoiceInformation>, 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 /// Fields to remove from channel
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsChannel>, pub remove: Vec<FieldsChannel>,
+7 -2
View File
@@ -46,8 +46,13 @@ auto_derived!(
File, File,
/// File contains textual data and should be displayed as such /// File contains textual data and should be displayed as such
Text, Text,
/// File is an image with specific dimensions /// File is an image with specific dimensions, and may be animated
Image { width: usize, height: usize }, Image {
width: usize,
height: usize,
thumbhash: Option<Vec<u8>>,
animated: bool,
},
/// File is a video with specific dimensions /// File is a video with specific dimensions
Video { width: usize, height: usize }, Video { width: usize, height: usize },
/// File is audio /// File is audio
+9 -1
View File
@@ -133,7 +133,10 @@ auto_derived!(
#[serde(rename = "message_unpinned")] #[serde(rename = "message_unpinned")]
MessageUnpinned { id: String, by: String }, MessageUnpinned { id: String, by: String },
#[serde(rename = "call_started")] #[serde(rename = "call_started")]
CallStarted { by: String, finished_at: Option<Timestamp> }, CallStarted {
by: String,
finished_at: Option<Timestamp>,
},
} }
/// Name and / or avatar override information /// Name and / or avatar override information
@@ -201,6 +204,9 @@ auto_derived!(
pub image: Option<String>, pub image: Option<String>,
/// Message content or system message information /// Message content or system message information
pub body: String, 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 /// Unique tag, usually the channel ID
pub tag: String, pub tag: String,
/// Timestamp at which this notification was created /// Timestamp at which this notification was created
@@ -385,6 +391,7 @@ auto_derived!(
); );
/// Message Author Abstraction /// Message Author Abstraction
#[derive(Clone)]
pub enum MessageAuthor<'a> { pub enum MessageAuthor<'a> {
User(&'a User), User(&'a User),
Webhook(&'a Webhook), Webhook(&'a Webhook),
@@ -508,6 +515,7 @@ impl PushNotification {
icon, icon,
image, image,
body, body,
raw_body: None,
tag: channel.id().to_string(), tag: channel.id().to_string(),
timestamp, timestamp,
url: format!("{}/channel/{}/{}", config.hosts.app, channel.id(), msg.id), url: format!("{}/channel/{}/{}", config.hosts.app, channel.id(), msg.id),
+3
View File
@@ -19,6 +19,9 @@ auto_derived!(
/// Ban reason /// Ban reason
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))] #[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))]
pub reason: Option<String>, 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 /// Just enough information to list a ban
+3
View File
@@ -252,6 +252,9 @@ auto_derived!(
/// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg). /// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg).
pub analytics: Option<bool>, pub analytics: Option<bool>,
/// User id of the new owner
pub owner: Option<String>,
/// Fields to remove from server object /// Fields to remove from server object
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsServer>, pub remove: Vec<FieldsServer>,
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-parser" name = "revolt-parser"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"] authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
+125 -42
View File
@@ -16,23 +16,29 @@ pub enum MessageToken<'a> {
UserMention(&'a str), UserMention(&'a str),
#[regex("<%(?&id)>", |lex| &lex.slice()[2..lex.slice().len() - 1],)] #[regex("<%(?&id)>", |lex| &lex.slice()[2..lex.slice().len() - 1],)]
RoleMention(&'a str), 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")] #[token("@everyone")]
MentionEveryone, MentionEveryone,
#[token("@online")] #[token("@online")]
MentionOnline MentionOnline,
} }
#[derive(Debug, Clone, PartialEq, Default)] #[derive(Debug, Clone, PartialEq, Default)]
pub struct MessageResults { pub struct MessageResults {
pub user_mentions: HashSet<String>, pub user_mentions: HashSet<String>,
pub role_mentions: HashSet<String>, pub role_mentions: HashSet<String>,
pub channel_mentions: HashSet<String>,
pub emojis: HashSet<String>,
pub mentions_everyone: bool, pub mentions_everyone: bool,
pub mentions_online: bool pub mentions_online: bool,
} }
struct MessageParserIterator<'a, I> { struct MessageParserIterator<'a, I> {
inner: I, inner: I,
temp: VecDeque<MessageToken<'a>> temp: VecDeque<MessageToken<'a>>,
} }
impl<'a, I: Iterator<Item = MessageToken<'a>>> Iterator for MessageParserIterator<'a, I> { 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)) { if next_token == Some(MessageToken::CodeblockMarker(ty)) {
self.temp.clear(); self.temp.clear();
self.temp.push_back(MessageToken::CodeblockMarker(ty)); self.temp.push_back(MessageToken::CodeblockMarker(ty));
break next_token break next_token;
} else if let Some(token) = next_token { } else if let Some(token) = next_token {
self.temp.push_back(token); self.temp.push_back(token);
} else { } else {
break Some(MessageToken::CodeblockMarker(ty)) break Some(MessageToken::CodeblockMarker(ty));
} }
} }
} else { } 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 { MessageParserIterator {
inner: MessageToken::lexer(text).flatten(), 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) { for token in parse_message_iter(text) {
match token { match token {
MessageToken::Escape => {} MessageToken::Escape => {}
MessageToken::CodeblockMarker(_) => {}, MessageToken::CodeblockMarker(_) => {}
MessageToken::UserMention(id) => { results.user_mentions.insert(id.to_string()); }, MessageToken::UserMention(id) => {
MessageToken::RoleMention(id) => { results.role_mentions.insert(id.to_string()); }, 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::MentionEveryone => results.mentions_everyone = true,
MessageToken::MentionOnline => results.mentions_online = true, MessageToken::MentionOnline => results.mentions_online = true,
}; };
}; }
results results
} }
@@ -109,7 +125,10 @@ mod tests {
let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>.").collect::<Vec<_>>(); let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>.").collect::<Vec<_>>();
assert_eq!(output.len(), 1); assert_eq!(output.len(), 1);
assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); assert_eq!(
output[0],
MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
} }
#[test] #[test]
@@ -117,7 +136,10 @@ mod tests {
let output = parse_message_iter("Hello <%01FD58YK5W7QRV5H3D64KTQYX3>.").collect::<Vec<_>>(); let output = parse_message_iter("Hello <%01FD58YK5W7QRV5H3D64KTQYX3>.").collect::<Vec<_>>();
assert_eq!(output.len(), 1); assert_eq!(output.len(), 1);
assert_eq!(output[0], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); assert_eq!(
output[0],
MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")
);
} }
#[test] #[test]
@@ -138,29 +160,57 @@ mod tests {
#[test] #[test]
fn test_everything() { 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.len(), 6);
assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); assert_eq!(
assert_eq!(output[1], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); output[0],
assert_eq!(output[2], MessageToken::MentionEveryone); MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
assert_eq!(output[3], MessageToken::MentionOnline); );
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] #[test]
fn test_everything_no_spaces() { 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.len(), 6);
assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); assert_eq!(
assert_eq!(output[1], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); output[0],
assert_eq!(output[2], MessageToken::MentionEveryone); MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
assert_eq!(output[3], MessageToken::MentionOnline); );
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] #[test]
fn test_codeblock_no_mentions() { 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.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(3)); assert_eq!(output[0], MessageToken::CodeblockMarker(3));
@@ -169,19 +219,36 @@ mod tests {
#[test] #[test]
fn test_uncontained_codeblock_should_mention() { 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[0], MessageToken::CodeblockMarker(3));
assert_eq!(output[1], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); assert_eq!(
assert_eq!(output[2], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); output[1],
assert_eq!(output[3], MessageToken::MentionEveryone); MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
assert_eq!(output[4], MessageToken::MentionOnline); );
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] #[test]
fn test_inline_codeblock_no_mentions() { 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.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(1)); assert_eq!(output[0], MessageToken::CodeblockMarker(1));
@@ -190,19 +257,33 @@ mod tests {
#[test] #[test]
fn test_uncontained_inline_codeblock_should_mention() { 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[0], MessageToken::CodeblockMarker(1));
assert_eq!(output[1], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); assert_eq!(
assert_eq!(output[2], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); output[1],
assert_eq!(output[3], MessageToken::MentionEveryone); MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")
assert_eq!(output[4], MessageToken::MentionOnline); );
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] #[test]
fn test_codeblock_with_language_no_mentions() { 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.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(3)); assert_eq!(output[0], MessageToken::CodeblockMarker(3));
@@ -220,7 +301,8 @@ mod tests {
#[test] #[test]
fn test_double_inline_codeblock_with_backticks_inside() { 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.len(), 2);
assert_eq!(output[0], MessageToken::CodeblockMarker(2)); assert_eq!(output[0], MessageToken::CodeblockMarker(2));
@@ -238,7 +320,8 @@ mod tests {
#[test] #[test]
fn test_escaped_codeblock() { 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.len(), 3);
assert_eq!(output[0], MessageToken::Escape); assert_eq!(output[0], MessageToken::Escape);
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-permissions" name = "revolt-permissions"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -22,7 +22,7 @@ async-std = { version = "1.8.0", features = ["attributes"] }
[dependencies] [dependencies]
# Core # Core
revolt-result = { version = "0.11.1", path = "../result" } revolt-result = { version = "0.12.0", path = "../result" }
# Utility # Utility
auto_ops = "0.3.0" auto_ops = "0.3.0"
@@ -75,6 +75,8 @@ pub enum ChannelPermission {
Masquerade = 1 << 28, Masquerade = 1 << 28,
/// React to messages with emojis /// React to messages with emojis
React = 1 << 29, React = 1 << 29,
/// Bypass slowmode
BypassSlowmode = 1 << 39,
// * Voice permissions // * Voice permissions
/// Connect to a voice channel /// Connect to a voice channel
@@ -99,7 +101,7 @@ pub enum ChannelPermission {
MentionRoles = 1 << 38, MentionRoles = 1 << 38,
// * Misc. permissions // * Misc. permissions
// % Bits 38 to 52: free area // % Bits 39 to 52: free area
// % Bits 53 to 64: do not use // % Bits 53 to 64: do not use
// * Grant all permissions // * 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_SAVED_MESSAGES: u64 = ChannelPermission::GrantAllSafe as u64;
pub static DEFAULT_PERMISSION_DIRECT_MESSAGE: Lazy<u64> = Lazy::new(|| { 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(|| { pub static DEFAULT_PERMISSION_SERVER: Lazy<u64> = Lazy::new(|| {
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-presence" name = "revolt-presence"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -17,7 +17,7 @@ redis-is-patched = []
async-std = { version = "1.8.0", features = ["attributes"] } async-std = { version = "1.8.0", features = ["attributes"] }
# Config for loading Redis URI # Config for loading Redis URI
revolt-config = { version = "0.11.1", path = "../config" } revolt-config = { version = "0.12.0", path = "../config" }
[dependencies] [dependencies]
# Utility # Utility
+4 -4
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-ratelimits" name = "revolt-ratelimits"
version = "0.11.1" version = "0.12.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"] authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
@@ -18,9 +18,9 @@ axum = ["dep:axum", "revolt-database/axum-impl"]
default = ["rocket", "axum"] default = ["rocket", "axum"]
[dependencies] [dependencies]
revolt-database = { version = "0.11.1", path = "../database" } revolt-database = { version = "0.12.0", path = "../database" }
revolt-result = { version = "0.11.1", path = "../result" } revolt-result = { version = "0.12.0", path = "../result" }
revolt-config = { version = "0.11.1", path = "../config" } revolt-config = { version = "0.12.0", path = "../config" }
rocket = { version = "0.5.1", optional = true } rocket = { version = "0.5.1", optional = true }
revolt_rocket_okapi = { version = "0.10.0", optional = true } revolt_rocket_okapi = { version = "0.10.0", optional = true }
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-result" name = "revolt-result"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
+4
View File
@@ -36,7 +36,11 @@ impl IntoResponse for Error {
ErrorType::NotInGroup => StatusCode::NOT_FOUND, ErrorType::NotInGroup => StatusCode::NOT_FOUND,
ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST, ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST,
ErrorType::NotPinned => 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::UnknownServer => StatusCode::NOT_FOUND,
ErrorType::InvalidRole => StatusCode::NOT_FOUND, ErrorType::InvalidRole => StatusCode::NOT_FOUND,
ErrorType::Banned => StatusCode::FORBIDDEN, ErrorType::Banned => StatusCode::FORBIDDEN,
+9 -8
View File
@@ -1,5 +1,5 @@
use std::panic::Location;
use std::fmt::Display; use std::fmt::Display;
use std::panic::Location;
#[cfg(feature = "serde")] #[cfg(feature = "serde")]
#[macro_use] #[macro_use]
@@ -102,8 +102,12 @@ pub enum ErrorType {
NotInGroup, NotInGroup,
AlreadyPinned, AlreadyPinned,
NotPinned, NotPinned,
InSlowmode {
retry_after: u64,
},
// ? Server related errors // ? Server related errors
CantCreateServers,
UnknownServer, UnknownServer,
InvalidRole, InvalidRole,
Banned, Banned,
@@ -232,15 +236,14 @@ impl<T, E: std::fmt::Debug + std::error::Error> ToRevoltError<T> for Result<T, E
fn to_internal_error(self) -> Result<T, Error> { fn to_internal_error(self) -> Result<T, Error> {
let loc = Location::caller(); let loc = Location::caller();
self self.map_err(|e| {
.map_err(|e| {
log::error!("{e:?}"); log::error!("{e:?}");
#[cfg(feature = "sentry")] #[cfg(feature = "sentry")]
sentry::capture_error(&e); sentry::capture_error(&e);
Error { Error {
error_type: ErrorType::InternalError, error_type: ErrorType::InternalError,
location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()) location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()),
} }
}) })
} }
@@ -251,11 +254,9 @@ impl<T> ToRevoltError<T> for Option<T> {
fn to_internal_error(self) -> Result<T, Error> { fn to_internal_error(self) -> Result<T, Error> {
let loc = Location::caller(); let loc = Location::caller();
self.ok_or_else(|| { self.ok_or_else(|| Error {
Error {
error_type: ErrorType::InternalError, error_type: ErrorType::InternalError,
location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()) location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()),
}
}) })
} }
} }
+4
View File
@@ -42,8 +42,12 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::NotInGroup => Status::NotFound, ErrorType::NotInGroup => Status::NotFound,
ErrorType::AlreadyPinned => Status::BadRequest, ErrorType::AlreadyPinned => Status::BadRequest,
ErrorType::NotPinned => Status::BadRequest, ErrorType::NotPinned => Status::BadRequest,
ErrorType::InSlowmode {
retry_after: _,
} => Status::TooManyRequests,
ErrorType::InvalidFlagValue => Status::BadRequest, ErrorType::InvalidFlagValue => Status::BadRequest,
ErrorType::CantCreateServers => Status::Forbidden,
ErrorType::UnknownServer => Status::NotFound, ErrorType::UnknownServer => Status::NotFound,
ErrorType::InvalidRole => Status::NotFound, ErrorType::InvalidRole => Status::NotFound,
ErrorType::Banned => Status::Forbidden, ErrorType::Banned => Status::Forbidden,
+5 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-crond" name = "revolt-crond"
version = "0.11.1" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
edition = "2021" edition = "2021"
@@ -17,7 +17,7 @@ log = "0.4"
tokio = { version = "1" } tokio = { version = "1" }
# Core # Core
revolt-database = { version = "0.11.1", path = "../../core/database" } revolt-database = { version = "0.12.0", path = "../../core/database" }
revolt-result = { version = "0.11.1", path = "../../core/result" } revolt-result = { version = "0.12.0", path = "../../core/result" }
revolt-config = { version = "0.11.1", path = "../../core/config" } revolt-config = { version = "0.12.0", path = "../../core/config" }
revolt-files = { version = "0.11.1", path = "../../core/files" } revolt-files = { version = "0.12.0", path = "../../core/files" }
+1
View File
@@ -5,6 +5,7 @@ FROM debian:12 AS debian
# Bundle Stage # Bundle Stage
FROM gcr.io/distroless/cc-debian12:nonroot FROM gcr.io/distroless/cc-debian12:nonroot
COPY --from=builder /home/rust/src/target/release/revolt-crond ./ COPY --from=builder /home/rust/src/target/release/revolt-crond ./
COPY --from=debian /usr/bin/uname /usr/bin/uname
USER nonroot USER nonroot
CMD ["./revolt-crond"] CMD ["./revolt-crond"]
+10 -6
View File
@@ -1,23 +1,24 @@
[package] [package]
name = "revolt-pushd" name = "revolt-pushd"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
publish = false publish = false
[dependencies] [dependencies]
revolt-result = { version = "0.11.1", path = "../../core/result" } revolt-result = { version = "0.12.0", path = "../../core/result" }
revolt-config = { version = "0.11.1", path = "../../core/config", features = [ revolt-config = { version = "0.12.0", path = "../../core/config", features = [
"report-macros", "report-macros",
"anyhow", "anyhow",
] } ] }
revolt-database = { version = "0.11.1", path = "../../core/database" } revolt-database = { version = "0.12.0", path = "../../core/database" }
revolt-models = { version = "0.11.1", path = "../../core/models", features = [ revolt-models = { version = "0.12.0", path = "../../core/models", features = [
"validator", "validator",
] } ] }
revolt-presence = { version = "0.11.1", path = "../../core/presence", features = [ revolt-presence = { version = "0.12.0", path = "../../core/presence", features = [
"redis-is-patched", "redis-is-patched",
] } ] }
revolt-parser = { version = "0.12.0", path = "../../core/parser" }
anyhow = { version = "1.0.98" } anyhow = { version = "1.0.98" }
@@ -26,6 +27,7 @@ fcm_v1 = "0.3.0"
web-push = "0.10.0" web-push = "0.10.0"
isahc = { optional = true, version = "1.7", features = ["json"] } isahc = { optional = true, version = "1.7", features = ["json"] }
revolt_a2 = { version = "0.10", default-features = false, features = ["ring"] } revolt_a2 = { version = "0.10", default-features = false, features = ["ring"] }
redis-kiss = "0.1.4"
tokio = "1.39.2" tokio = "1.39.2"
async-trait = "0.1.81" async-trait = "0.1.81"
ulid = "1.0.0" ulid = "1.0.0"
@@ -35,6 +37,8 @@ authifier = "1.0.16"
log = "0.4.11" log = "0.4.11"
pretty_env_logger = "0.4.0" pretty_env_logger = "0.4.0"
regex = "1.12.3"
#serialization #serialization
serde_json = "1" serde_json = "1"
revolt_optional_struct = "0.2.0" revolt_optional_struct = "0.2.0"
@@ -128,7 +128,7 @@ impl DmCallConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());
@@ -101,7 +101,7 @@ impl FRAcceptedConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());
@@ -101,7 +101,7 @@ impl FRReceivedConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());
@@ -107,7 +107,7 @@ impl GenericConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());
@@ -3,7 +3,7 @@ use std::{
hash::RandomState, hash::RandomState,
}; };
use crate::consumers::inbound::internal::*; use crate::{consumers::inbound::internal::*, utils};
use amqprs::{ use amqprs::{
channel::{BasicPublishArguments, Channel}, channel::{BasicPublishArguments, Channel},
connection::Connection, connection::Connection,
@@ -17,6 +17,7 @@ use revolt_database::{
MessageFlagsValue, MessageFlagsValue,
}; };
use revolt_models::v0::{MessageFlags, PushNotification}; use revolt_models::v0::{MessageFlags, PushNotification};
use revolt_result::ToRevoltError;
pub struct MassMessageConsumer { pub struct MassMessageConsumer {
#[allow(dead_code)] #[allow(dead_code)]
@@ -104,7 +105,7 @@ impl MassMessageConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());
@@ -129,7 +130,17 @@ impl MassMessageConsumer {
) -> Result<()> { ) -> Result<()> {
let config = revolt_config::config().await; let config = revolt_config::config().await;
let content = String::from_utf8(content)?; 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"); debug!("Received mass message event");
@@ -1,6 +1,6 @@
use std::collections::HashMap; use std::collections::HashMap;
use crate::consumers::inbound::internal::*; use crate::{consumers::inbound::internal::*, utils};
use amqprs::{ use amqprs::{
channel::{BasicPublishArguments, Channel}, channel::{BasicPublishArguments, Channel},
connection::Connection, connection::Connection,
@@ -11,6 +11,7 @@ use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use log::debug; use log::debug;
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
use revolt_result::ToRevoltError;
pub struct MessageConsumer { pub struct MessageConsumer {
#[allow(dead_code)] #[allow(dead_code)]
@@ -64,7 +65,15 @@ impl MessageConsumer {
content: Vec<u8>, content: Vec<u8>,
) -> Result<()> { ) -> Result<()> {
let content = String::from_utf8(content)?; 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"); debug!("Received message event on origin");
@@ -107,7 +116,7 @@ impl MessageConsumer {
config.pushd.vapid.queue.as_str(), config.pushd.vapid.queue.as_str(),
) )
.finish(); .finish();
sendable.extras.insert("p265dh".to_string(), sub.p256dh); sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable sendable
.extras .extras
.insert("endpoint".to_string(), sub.endpoint.clone()); .insert("endpoint".to_string(), sub.endpoint.clone());
+1
View File
@@ -14,6 +14,7 @@ use revolt_config::{config, Settings};
use tokio::sync::Notify; use tokio::sync::Notify;
mod consumers; mod consumers;
mod utils;
use consumers::{ use consumers::{
inbound::{ inbound::{
ack::AckConsumer, dm_call::DmCallConsumer, fr_accepted::FRAcceptedConsumer, ack::AckConsumer, dm_call::DmCallConsumer, fr_accepted::FRAcceptedConsumer,
+2
View File
@@ -0,0 +1,2 @@
mod renderer;
pub use renderer::render_notification_content;
+272
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);
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-voice-ingress" name = "revolt-voice-ingress"
version = "0.11.1" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
publish = false publish = false
+38 -16
View File
@@ -5,9 +5,9 @@ use revolt_database::{
iso8601_timestamp::{Duration, Timestamp}, iso8601_timestamp::{Duration, Timestamp},
util::reference::Reference, util::reference::Reference,
voice::{ voice::{
create_voice_state, delete_voice_state, create_voice_state, delete_channel_voice_state, delete_voice_state,
get_user_moved_from_voice, get_user_moved_to_voice, get_user_moved_from_voice, get_user_moved_to_voice, update_voice_state_tracks,
update_voice_state_tracks, VoiceClient, RoomMetadata, UserVoiceChannel, VoiceClient,
}, },
Database, AMQP, Database, AMQP,
}; };
@@ -51,27 +51,34 @@ pub async fn ingress(
let channel_id = event.room.as_ref().map(|r| &r.name); let channel_id = event.room.as_ref().map(|r| &r.name);
let user_id = event.participant.as_ref().map(|r| &r.identity); 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() { match event.event.as_str() {
// User joined a channel // User joined a channel
"participant_joined" => { "participant_joined" => {
let channel_id = channel_id.to_internal_error()?; let channel_id = channel_id.to_internal_error()?;
let user_id = user_id.to_internal_error()?; let user_id = user_id.to_internal_error()?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
let joined_at = Timestamp::UNIX_EPOCH let joined_at = Timestamp::UNIX_EPOCH
.checked_add(Duration::seconds(event.created_at)) .checked_add(Duration::seconds(event.created_at))
.unwrap(); .unwrap();
let voice_state = let voice_state = create_voice_state(&channel, user_id, joined_at).await?;
create_voice_state(channel_id, channel.server(), user_id, joined_at).await?;
// Only publish one event when a user is moved from one channel to another. // 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? { if let Some(moved_from) = get_user_moved_to_voice(channel_id, user_id).await? {
EventV1::VoiceChannelMove { EventV1::VoiceChannelMove {
user: user_id.to_string(), user: user_id.to_string(),
from: moved_from, from: moved_from.id,
to: channel_id.to_string(), to: channel_id.to_string(),
state: voice_state, state: voice_state,
} }
@@ -136,10 +143,13 @@ pub async fn ingress(
"participant_left" => { "participant_left" => {
let channel_id = channel_id.to_internal_error()?; let channel_id = channel_id.to_internal_error()?;
let user_id = user_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, user_id).await?;
delete_voice_state(channel_id, channel.server(), user_id).await?;
// Dont send leave event when a user is moved // Dont send leave event when a user is moved
if get_user_moved_from_voice(channel_id, user_id) 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 channel_id = channel_id.to_internal_error()?;
let user_id = user_id.to_internal_error()?; let user_id = user_id.to_internal_error()?;
let track = event.track.as_ref().to_internal_error()?; let track = event.track.as_ref().to_internal_error()?;
let server_id = room_metadata.to_internal_error()?.server;
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; let channel = UserVoiceChannel {
id: channel_id.clone(),
server_id: server_id.clone(),
};
let user = Reference::from_unchecked(user_id).as_user(db).await?; 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."); 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; 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); return Ok(EmptyResponse);
}; };
}; };
let partial = update_voice_state_tracks( let partial = update_voice_state_tracks(
channel_id, &channel,
channel.server(),
user_id, user_id,
event.event == "track_published" || event.event == "track_unmuted", // to avoid duplicating this entire case twice event.event == "track_published" || event.event == "track_unmuted", // to avoid duplicating this entire case twice
track.source, track.source,
@@ -267,6 +279,16 @@ pub async fn ingress(
.p(channel_id.clone()) .p(channel_id.clone())
.await; .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?;
}
_ => {} _ => {}
}; };
+1 -1
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-delta" name = "revolt-delta"
version = "0.11.1" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <paulmakles@gmail.com>"] authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018" edition = "2018"
+9 -5
View File
@@ -1,4 +1,8 @@
use revolt_database::{Database, User, util::reference::Reference, voice::{VoiceClient, remove_user_from_voice_channels}}; use revolt_database::{
util::reference::Reference,
voice::{remove_user_from_voice_channels, VoiceClient},
Database, User,
};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use rocket::State; use rocket::State;
use rocket_empty::EmptyResponse; use rocket_empty::EmptyResponse;
@@ -7,21 +11,21 @@ use rocket_empty::EmptyResponse;
/// ///
/// Delete a bot by its id. /// Delete a bot by its id.
#[openapi(tag = "Bots")] #[openapi(tag = "Bots")]
#[delete("/<target>")] #[delete("/<bot_id>")]
pub async fn delete_bot( pub async fn delete_bot(
db: &State<Database>, db: &State<Database>,
voice_client: &State<VoiceClient>, voice_client: &State<VoiceClient>,
user: User, user: User,
target: Reference<'_>, bot_id: Reference<'_>,
) -> Result<EmptyResponse> { ) -> Result<EmptyResponse> {
let bot = target.as_bot(db).await?; let bot = bot_id.as_bot(db).await?;
if bot.owner != user.id { if bot.owner != user.id {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
bot.delete(db).await?; bot.delete(db).await?;
remove_user_from_voice_channels(db, voice_client, &bot.id).await?; remove_user_from_voice_channels(voice_client, &bot.id).await?;
Ok(EmptyResponse) Ok(EmptyResponse)
} }
+3 -3
View File
@@ -10,11 +10,11 @@ use validator::Validate;
/// ///
/// Edit bot details by its id. /// Edit bot details by its id.
#[openapi(tag = "Bots")] #[openapi(tag = "Bots")]
#[patch("/<target>", data = "<data>")] #[patch("/<bot_id>", data = "<data>")]
pub async fn edit_bot( pub async fn edit_bot(
db: &State<Database>, db: &State<Database>,
user: User, user: User,
target: Reference<'_>, bot_id: Reference<'_>,
data: Json<DataEditBot>, data: Json<DataEditBot>,
) -> Result<Json<v0::BotWithUserResponse>> { ) -> Result<Json<v0::BotWithUserResponse>> {
let data = data.into_inner(); let data = data.into_inner();
@@ -24,7 +24,7 @@ pub async fn edit_bot(
}) })
})?; })?;
let mut bot = target.as_bot(db).await?; let mut bot = bot_id.as_bot(db).await?;
if bot.owner != user.id { if bot.owner != user.id {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
+3 -3
View File
@@ -7,17 +7,17 @@ use rocket::{serde::json::Json, State};
/// ///
/// Fetch details of a bot you own by its id. /// Fetch details of a bot you own by its id.
#[openapi(tag = "Bots")] #[openapi(tag = "Bots")]
#[get("/<bot>")] #[get("/<bot_id>")]
pub async fn fetch_bot( pub async fn fetch_bot(
db: &State<Database>, db: &State<Database>,
user: User, user: User,
bot: Reference<'_>, bot_id: Reference<'_>,
) -> Result<Json<FetchBotResponse>> { ) -> Result<Json<FetchBotResponse>> {
if user.bot.is_some() { if user.bot.is_some() {
return Err(create_error!(IsBot)); return Err(create_error!(IsBot));
} }
let bot = bot.as_bot(db).await?; let bot = bot_id.as_bot(db).await?;
if bot.owner != user.id { if bot.owner != user.id {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
@@ -1,7 +1,10 @@
use revolt_database::{ use revolt_database::{
AMQP, Channel, Database, PartialChannel, User, util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{ util::{permissions::DatabasePermissionQuery, reference::Reference},
VoiceClient, delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel voice::{
} delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel,
UserVoiceChannel, VoiceClient,
},
Channel, Database, PartialChannel, User, AMQP,
}; };
use revolt_models::v0; use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -54,15 +57,17 @@ pub async fn delete(
) )
.await?; .await?;
if is_in_voice_channel(&user.id, channel.id()).await? { let user_voice_channel = UserVoiceChannel::from_channel(&channel);
remove_user_from_voice_channel(db, voice_client, channel.id(), &user.id).await?;
if is_in_voice_channel(&user.id, &user_voice_channel).await? {
remove_user_from_voice_channel(voice_client, &user_voice_channel, &user.id).await?;
}; };
} }
Channel::TextChannel { .. } => { Channel::TextChannel { .. } => {
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?;
channel.delete(db).await?; channel.delete(db).await?;
delete_voice_channel(voice_client, channel.id(), channel.server()).await?; delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?;
} }
}; };
@@ -1,6 +1,6 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{delete_voice_channel, VoiceClient}, voice::{delete_voice_channel, UserVoiceChannel, VoiceClient},
Channel, Database, File, PartialChannel, SystemMessage, User, AMQP, Channel, Database, File, PartialChannel, SystemMessage, User, AMQP,
}; };
use revolt_models::v0; use revolt_models::v0;
@@ -41,6 +41,7 @@ pub async fn edit(
&& data.nsfw.is_none() && data.nsfw.is_none()
&& data.owner.is_none() && data.owner.is_none()
&& data.voice.is_none() && data.voice.is_none()
&& data.slowmode.is_none()
&& data.remove.is_empty() && data.remove.is_empty()
{ {
return Ok(Json(channel.into())); return Ok(Json(channel.into()));
@@ -200,6 +201,7 @@ pub async fn edit(
icon, icon,
nsfw, nsfw,
voice, voice,
slowmode,
.. ..
} => { } => {
if data.remove.contains(&v0::FieldsChannel::Icon) { if data.remove.contains(&v0::FieldsChannel::Icon) {
@@ -247,6 +249,11 @@ pub async fn edit(
*voice = Some(new_voice.clone().into()); *voice = Some(new_voice.clone().into());
partial.voice = Some(new_voice.into()); partial.voice = Some(new_voice.into());
} }
if let Some(new_slowmode) = data.slowmode {
*slowmode = Some(new_slowmode);
partial.slowmode = Some(new_slowmode);
}
} }
_ => return Err(create_error!(InvalidOperation)), _ => return Err(create_error!(InvalidOperation)),
}; };
@@ -260,7 +267,7 @@ pub async fn edit(
.await?; .await?;
if channel.voice().is_none() { if channel.voice().is_none() {
delete_voice_channel(voice_client, channel.id(), channel.server()).await?; delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?;
} }
Ok(Json(channel.into())) Ok(Json(channel.into()))
@@ -1,7 +1,5 @@
use revolt_database::{ use revolt_database::{
util::reference::Reference, AMQP, Channel, Database, User, util::reference::Reference, voice::{UserVoiceChannel, VoiceClient, is_in_voice_channel, remove_user_from_voice_channel}
voice::{is_in_voice_channel, remove_user_from_voice_channel, VoiceClient},
Channel, Database, User, AMQP,
}; };
use revolt_permissions::ChannelPermission; use revolt_permissions::ChannelPermission;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
@@ -13,20 +11,20 @@ use rocket_empty::EmptyResponse;
/// ///
/// Removes a user from the group. /// Removes a user from the group.
#[openapi(tag = "Groups")] #[openapi(tag = "Groups")]
#[delete("/<target>/recipients/<member>")] #[delete("/<group_id>/recipients/<member_id>")]
pub async fn remove_member( pub async fn remove_member(
db: &State<Database>, db: &State<Database>,
voice_client: &State<VoiceClient>, voice_client: &State<VoiceClient>,
amqp: &State<AMQP>, amqp: &State<AMQP>,
user: User, user: User,
target: Reference<'_>, group_id: Reference<'_>,
member: Reference<'_>, member_id: Reference<'_>,
) -> Result<EmptyResponse> { ) -> Result<EmptyResponse> {
if user.bot.is_some() { if user.bot.is_some() {
return Err(create_error!(IsBot)); return Err(create_error!(IsBot));
} }
let channel = target.as_channel(db).await?; let channel = group_id.as_channel(db).await?;
if let Channel::Group { if let Channel::Group {
owner, recipients, .. owner, recipients, ..
@@ -38,7 +36,7 @@ pub async fn remove_member(
})); }));
} }
let member = member.as_user(db).await?; let member = member_id.as_user(db).await?;
if user.id == member.id { if user.id == member.id {
return Err(create_error!(CannotRemoveYourself)); return Err(create_error!(CannotRemoveYourself));
} }
@@ -54,8 +52,10 @@ pub async fn remove_member(
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation));
}; };
if is_in_voice_channel(&member.id, channel.id()).await? { let user_voice_channel = UserVoiceChannel::from_channel(&channel);
remove_user_from_voice_channel(db, voice_client, channel.id(), &member.id).await?;
if is_in_voice_channel(member_id.id, &user_voice_channel).await? {
remove_user_from_voice_channel(voice_client, &user_voice_channel, member_id.id).await?;
}; };
Ok(EmptyResponse) Ok(EmptyResponse)
@@ -1,9 +1,10 @@
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use redis_kiss::{get_connection, redis, AsyncCommands};
use revolt_database::util::permissions::DatabasePermissionQuery; use revolt_database::util::permissions::DatabasePermissionQuery;
use revolt_database::{ use revolt_database::{
util::idempotency::IdempotencyKey, util::reference::Reference, Database, User, util::idempotency::IdempotencyKey, util::reference::Reference, Database, User,
}; };
use revolt_database::{Interactions, Message, AMQP}; use revolt_database::{Channel, Interactions, Message, AMQP};
use revolt_models::v0; use revolt_models::v0;
use revolt_permissions::PermissionQuery; use revolt_permissions::PermissionQuery;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -57,6 +58,50 @@ pub async fn message_send(
permissions.throw_if_lacking_channel_permission(ChannelPermission::UploadFiles)?; permissions.throw_if_lacking_channel_permission(ChannelPermission::UploadFiles)?;
} }
if !permissions.has_channel_permission(ChannelPermission::BypassSlowmode) {
if let Channel::TextChannel {
slowmode: Some(channel_slowmode),
id: channel_id,
..
} = &channel
{
if *channel_slowmode > 0 {
if let Ok(conn) = get_connection().await {
let mut conn = conn.into_inner();
let slowmode_key = format!("slowmode:{}:{}", user.id, channel_id);
// Atomic check-and-set: only set if absent and apply expiry in one command.
let set_result: Option<String> = conn
.set_options(
&slowmode_key,
"1", // The value doesn't matter, only the key's existence
redis::SetOptions::default()
.conditional_set(redis::ExistenceCheck::NX)
.with_expiration(redis::SetExpiry::EX(*channel_slowmode as usize)),
)
.await
.unwrap_or(None);
// If `set_result` is None, the `NX` condition failed because the key already exists.
// This means the user is currently in slowmode.
if set_result.is_none() {
// Fetch the remaining TTL to accurately populate the retry_after field
let ttl: i64 = conn.ttl(&slowmode_key).await.unwrap_or(0);
// Redis returns positive integers for valid TTLs
if ttl > 0 {
return Err(create_error!(InSlowmode {
retry_after: ttl as u64
}));
}
}
}
// If Redis connection fails, just skip the slowmode check
}
}
}
// Ensure interactions information is correct // Ensure interactions information is correct
if let Some(interactions) = &data.interactions { if let Some(interactions) = &data.interactions {
let interactions: Interactions = interactions.clone().into(); let interactions: Interactions = interactions.clone().into();
@@ -186,6 +231,7 @@ mod test {
}), }),
last_message_id: None, last_message_id: None,
voice: None, voice: None,
slowmode: None,
}; };
locked_channel locked_channel
.update(&harness.db, partial, vec![]) .update(&harness.db, partial, vec![])
+17 -11
View File
@@ -3,7 +3,8 @@ use revolt_database::{
util::{permissions::perms, reference::Reference}, util::{permissions::perms, reference::Reference},
voice::{ voice::{
delete_voice_state, get_channel_node, get_user_voice_channels, get_voice_channel_members, delete_voice_state, get_channel_node, get_user_voice_channels, get_voice_channel_members,
raise_if_in_voice, set_call_notification_recipients, VoiceClient, raise_if_in_voice, set_call_notification_recipients, set_channel_node, UserVoiceChannel,
VoiceClient,
}, },
Database, User, Database, User,
}; };
@@ -50,7 +51,9 @@ pub async fn call(
let current_permissions = calculate_channel_permissions(&mut permissions).await; let current_permissions = calculate_channel_permissions(&mut permissions).await;
current_permissions.throw_if_lacking_channel_permission(ChannelPermission::Connect)?; current_permissions.throw_if_lacking_channel_permission(ChannelPermission::Connect)?;
if get_voice_channel_members(channel.id()) let user_voice_channel = UserVoiceChannel::from_channel(&channel);
if get_voice_channel_members(&user_voice_channel)
.await? .await?
.zip(voice_info.max_users) .zip(voice_info.max_users)
.is_some_and(|(ms, max_users)| ms.len() >= max_users) .is_some_and(|(ms, max_users)| ms.len() >= max_users)
@@ -60,6 +63,7 @@ pub async fn call(
} }
let existing_node = get_channel_node(channel.id()).await?; let existing_node = get_channel_node(channel.id()).await?;
let has_existing_node = existing_node.is_some(); // we move existing_node in the next statement so this is the quickest way to know if we need to set it.
let node = existing_node let node = existing_node
.or(node) .or(node)
@@ -78,20 +82,18 @@ pub async fn call(
// Finds and disconnects any existing voice connections by the user, // Finds and disconnects any existing voice connections by the user,
// should only ever loop once but just to cover our backs. // should only ever loop once but just to cover our backs.
for channel_id in get_user_voice_channels(&user.id).await? { for previous_channel in get_user_voice_channels(&user.id).await? {
if let Some(node) = get_channel_node(&channel_id).await? { if let Some(node) = get_channel_node(&previous_channel.id).await? {
// if this errors its just a mismatching state - ignore and proceed to still delete our state // if this errors its just a mismatching state - ignore and proceed to still delete our state
let _ = voice_client.remove_user(&node, &user.id, &channel_id).await; let _ = voice_client
.remove_user(&node, &user.id, &previous_channel.id)
.await;
}; };
let channel = Reference::from_unchecked(&channel_id) delete_voice_state(&previous_channel, &user.id).await?;
.as_channel(db)
.await?;
delete_voice_state(&channel_id, channel.server(), &user.id).await?;
} }
} else { } else {
raise_if_in_voice(&user, channel.id()).await?; raise_if_in_voice(&user, &user_voice_channel).await?;
} }
let token = voice_client let token = voice_client
@@ -100,6 +102,10 @@ pub async fn call(
let room = voice_client.create_room(&node, &channel).await?; let room = voice_client.create_room(&node, &channel).await?;
if !has_existing_node {
set_channel_node(channel.id(), &node).await?;
}
log::debug!("Created room {}", room.name); log::debug!("Created room {}", room.name);
if let Some(recipients) = recipients { if let Some(recipients) = recipients {
@@ -1,6 +1,6 @@
use revolt_database::{ use revolt_database::{
util::reference::Reference, util::reference::Reference,
voice::{get_voice_state, VoiceClient}, voice::{get_voice_state, UserVoiceChannel, VoiceClient},
Channel, Database, User, AMQP, Channel, Database, User, AMQP,
}; };
use revolt_result::{create_error, Result, ToRevoltError}; use revolt_result::{create_error, Result, ToRevoltError};
@@ -32,7 +32,13 @@ pub async fn stop_ring(
return Err(create_error!(NoEffect)); return Err(create_error!(NoEffect));
} }
if get_voice_state(channel.id(), None, &user.id) if get_voice_state(
&UserVoiceChannel {
id: channel.id().to_string(),
server_id: None,
},
&user.id,
)
.await? .await?
.is_none() .is_none()
{ {
@@ -46,7 +52,7 @@ pub async fn stop_ring(
_ => return Err(create_error!(NoEffect)), _ => return Err(create_error!(NoEffect)),
}; };
if members.iter().any(|m| &target_user.id == m) { if members.iter().any(|m| target_user.id == m) {
if let Err(e) = amqp if let Err(e) = amqp
.dm_call_updated( .dm_call_updated(
&user.id, &user.id,
@@ -15,11 +15,11 @@ use validator::Validate;
/// ///
/// Creates a webhook which 3rd party platforms can use to send messages /// Creates a webhook which 3rd party platforms can use to send messages
#[openapi(tag = "Webhooks")] #[openapi(tag = "Webhooks")]
#[post("/<target>/webhooks", data = "<data>")] #[post("/<channel_id>/webhooks", data = "<data>")]
pub async fn create_webhook( pub async fn create_webhook(
db: &State<Database>, db: &State<Database>,
user: User, user: User,
target: Reference<'_>, channel_id: Reference<'_>,
data: Json<v0::CreateWebhookBody>, data: Json<v0::CreateWebhookBody>,
) -> Result<Json<v0::Webhook>> { ) -> Result<Json<v0::Webhook>> {
let data = data.into_inner(); let data = data.into_inner();
@@ -29,7 +29,7 @@ pub async fn create_webhook(
}) })
})?; })?;
let channel = target.as_channel(db).await?; let channel = channel_id.as_channel(db).await?;
if !matches!(channel, Channel::TextChannel { .. } | Channel::Group { .. }) { if !matches!(channel, Channel::TextChannel { .. } | Channel::Group { .. }) {
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation));
@@ -11,11 +11,11 @@ use rocket::{serde::json::Json, State};
/// ///
/// Create an emoji by its Autumn upload id. /// Create an emoji by its Autumn upload id.
#[openapi(tag = "Emojis")] #[openapi(tag = "Emojis")]
#[put("/emoji/<id>", data = "<data>")] #[put("/emoji/<emoji_id>", data = "<data>")]
pub async fn create_emoji( pub async fn create_emoji(
db: &State<Database>, db: &State<Database>,
user: User, user: User,
id: String, emoji_id: String,
data: Json<v0::DataCreateEmoji>, data: Json<v0::DataCreateEmoji>,
) -> Result<Json<v0::Emoji>> { ) -> Result<Json<v0::Emoji>> {
let config = config().await; let config = config().await;
@@ -50,11 +50,11 @@ pub async fn create_emoji(
}; };
// Find the relevant attachment // Find the relevant attachment
let attachment = File::use_emoji(db, &id, &id, &user.id).await?; let attachment = File::use_emoji(db, &emoji_id, &emoji_id, &user.id).await?;
// Create the emoji object // Create the emoji object
let emoji = Emoji { let emoji = Emoji {
id, id: emoji_id,
parent: data.parent.into(), parent: data.parent.into(),
creator_id: user.id, creator_id: user.id,
name: data.name, name: data.name,
+1 -1
View File
@@ -216,7 +216,7 @@ fn custom_openapi_spec() -> OpenApi {
}), }),
license: Some(License { license: Some(License {
name: "AGPLv3".to_owned(), name: "AGPLv3".to_owned(),
url: Some("https://github.com/revoltchat/delta/blob/master/LICENSE".to_owned()), url: Some("https://github.com/stoatchat/stoatchat/blob/main/crates/delta/LICENSE".to_owned()),
..Default::default() ..Default::default()
}), }),
version: env!("CARGO_PKG_VERSION").to_string(), version: env!("CARGO_PKG_VERSION").to_string(),
+101
View File
@@ -2,6 +2,7 @@ use revolt_config::config;
use revolt_result::Result; use revolt_result::Result;
use rocket::serde::json::Json; use rocket::serde::json::Json;
use serde::Serialize; use serde::Serialize;
use std::collections::HashMap;
/// # hCaptcha Configuration /// # hCaptcha Configuration
#[derive(Serialize, JsonSchema, Debug)] #[derive(Serialize, JsonSchema, Debug)]
@@ -54,6 +55,87 @@ pub struct RevoltFeatures {
pub january: Feature, pub january: Feature,
/// Voice server configuration /// Voice server configuration
pub livekit: VoiceFeature, pub livekit: VoiceFeature,
/// Limits
pub limits: LimitsConfig,
}
/// # Limits For Users
#[derive(Serialize, JsonSchema, Debug)]
pub struct LimitsConfig {
/// Global Limits
pub global: GlobalLimits,
/// New User Limits
pub new_user: UserLimits,
/// Default User Limits
pub default: UserLimits,
}
/// # Global limits
#[derive(Serialize, JsonSchema, Debug)]
pub struct GlobalLimits {
/// max group size
group_size: i64,
/// max message embeds
message_embeds: i64,
/// max replies
message_replies: i64,
/// max reactions per message
message_reactions: i64,
/// max server emoji
server_emoji: i64,
/// max server roles
server_roles: i64,
/// max server channels
server_channels: i64,
body_limit_size: i64,
/// restrict server creation to these users.
/// if blank, all users can create servers
pub restrict_server_creation: Vec<String>,
}
/// # User Limits
#[derive(Serialize, JsonSchema, Debug)]
pub struct UserLimits {
/// Max Outgoing Friend Requests
pub outgoing_friend_requests: i64,
/// Max Owned Bots
pub bots: i64,
/// Max message content length
pub message_length: i64,
/// max message attachments
pub message_attachments: i64,
/// max servers
pub servers: i64,
/// max audio quality
pub voice_quality: i64,
/// video streaming enabled
pub video: bool,
/// max video resolution (vertical, horizontal)
pub video_resolution: [i64; 2],
/// min/max aspect ratios
pub video_aspect_ratio: [f64; 2],
pub file_upload_size_limits: HashMap<String, usize>,
}
impl UserLimits {
fn from_feature_limits(fl: revolt_config::FeaturesLimits) -> UserLimits {
UserLimits {
outgoing_friend_requests: fl.outgoing_friend_requests as i64,
bots: fl.bots as i64,
message_length: fl.message_length as i64,
message_attachments: fl.message_attachments as i64,
servers: fl.servers as i64,
voice_quality: fl.voice_quality as i64,
video: fl.video,
video_resolution: [fl.video_resolution[0] as i64, fl.video_resolution[1] as i64],
video_aspect_ratio: [
fl.video_aspect_ratio[0] as f64,
fl.video_aspect_ratio[1] as f64,
],
file_upload_size_limits: fl.file_upload_size_limit,
}
}
} }
/// # Build Information /// # Build Information
@@ -134,6 +216,25 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
}) })
.collect(), .collect(),
}, },
limits: LimitsConfig {
global: GlobalLimits {
group_size: config.features.limits.global.group_size as i64,
message_embeds: config.features.limits.global.message_embeds as i64,
message_replies: config.features.limits.global.message_replies as i64,
message_reactions: config.features.limits.global.message_reactions as i64,
server_emoji: config.features.limits.global.server_emoji as i64,
server_roles: config.features.limits.global.server_roles as i64,
server_channels: config.features.limits.global.server_channels as i64,
body_limit_size: config.features.limits.global.body_limit_size as i64,
restrict_server_creation: config
.features
.limits
.global
.restrict_server_creation,
},
new_user: UserLimits::from_feature_limits(config.features.limits.new_user),
default: UserLimits::from_feature_limits(config.features.limits.default),
},
}, },
ws: config.hosts.events, ws: config.hosts.events,
app: config.hosts.app, app: config.hosts.app,
+26 -4
View File
@@ -1,13 +1,19 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{get_user_voice_channel_in_server, remove_user_from_voice_channel, VoiceClient}, voice::{
Database, RemovalIntention, ServerBan, User, get_user_voice_channel_in_server, remove_user_from_voice_channel, UserVoiceChannel,
VoiceClient,
},
Database, Message, RemovalIntention, ServerBan, User,
}; };
use revolt_models::v0; use revolt_models::v0;
use std::time::{Duration, SystemTime};
use revolt_database::events::client::EventV1;
use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State}; use rocket::{serde::json::Json, State};
use ulid::Ulid;
use validator::Validate; use validator::Validate;
/// # Ban User /// # Ban User
@@ -58,11 +64,27 @@ pub async fn ban(
.await?; .await?;
// If the member is in a voice channel while banned kick them from the voice channel // If the member is in a voice channel while banned kick them from the voice channel
if let Some(channel_id) = get_user_voice_channel_in_server(&target.id, &server.id).await? { if let Some(channel_id) = get_user_voice_channel_in_server(target.id, &server.id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, &target.id).await?; remove_user_from_voice_channel(
voice_client,
&UserVoiceChannel {
id: channel_id,
server_id: Some(server.id.clone()),
},
target.id,
)
.await?;
} }
} }
// We do this outside the member check so we can sweep hit-and-run spammers who already left.
if let Some(seconds) = data.delete_message_seconds {
if seconds > 0 {
let threshold_time = SystemTime::now() - Duration::from_secs(seconds as u64);
Message::bulk_delete_by_author_since(db, &server.channels, target.id, threshold_time)
.await?;
}
}
ServerBan::create(db, &server, target.id, data.reason) ServerBan::create(db, &server, target.id, data.reason)
.await .await
.map(Into::into) .map(Into::into)
+29 -16
View File
@@ -9,15 +9,13 @@ use revolt_database::{
voice::{ voice::{
get_channel_node, get_user_voice_channel_in_server, set_channel_node, get_channel_node, get_user_voice_channel_in_server, set_channel_node,
set_user_moved_from_voice, set_user_moved_to_voice, sync_user_voice_permissions, set_user_moved_from_voice, set_user_moved_to_voice, sync_user_voice_permissions,
VoiceClient, UserVoiceChannel, VoiceClient,
}, },
Database, File, PartialMember, User, Database, File, PartialMember, User,
}; };
use revolt_models::v0::{self, FieldsMember}; use revolt_models::v0::{self, FieldsMember};
use revolt_permissions::{ use revolt_permissions::{calculate_channel_permissions, calculate_server_permissions, ChannelPermission, UserPermission};
calculate_channel_permissions, calculate_server_permissions, ChannelPermission,
};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use rocket::{form::validate::Contains, serde::json::Json, State}; use rocket::{form::validate::Contains, serde::json::Json, State};
use validator::Validate; use validator::Validate;
@@ -26,13 +24,13 @@ use validator::Validate;
/// ///
/// Edit a member by their id. /// Edit a member by their id.
#[openapi(tag = "Server Members")] #[openapi(tag = "Server Members")]
#[patch("/<server>/members/<member>", data = "<data>")] #[patch("/<server_id>/members/<member_id>", data = "<data>")]
pub async fn edit( pub async fn edit(
db: &State<Database>, db: &State<Database>,
voice_client: &State<VoiceClient>, voice_client: &State<VoiceClient>,
user: User, user: User,
server: Reference<'_>, server_id: Reference<'_>,
member: Reference<'_>, member_id: Reference<'_>,
data: Json<v0::DataMemberEdit>, data: Json<v0::DataMemberEdit>,
) -> Result<Json<v0::Member>> { ) -> Result<Json<v0::Member>> {
let data = data.into_inner(); let data = data.into_inner();
@@ -43,16 +41,18 @@ pub async fn edit(
})?; })?;
// Fetch server and member // Fetch server and member
let mut server = server.as_server(db).await?; let mut server = server_id.as_server(db).await?;
let target_user = member.as_user(db).await?; let target_user = member_id.as_user(db).await?;
let mut member = member.as_member(db, &server.id).await?; let mut member = member_id.as_member(db, &server.id).await?;
// Fetch our currrent permissions // Fetch our currrent permissions
let mut query = DatabasePermissionQuery::new(db, &user).server(&server); let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
let permissions = calculate_server_permissions(&mut query).await; let permissions = calculate_server_permissions(&mut query).await;
// Fetch target permissions // Fetch target permissions
let mut target_query = DatabasePermissionQuery::new(db, &target_user).server(&server).member(&member); let mut target_query = DatabasePermissionQuery::new(db, &target_user)
.server(&server)
.member(&member);
let target_permissions = calculate_server_permissions(&mut target_query).await; let target_permissions = calculate_server_permissions(&mut target_query).await;
// Check permissions in server // Check permissions in server
@@ -67,8 +67,10 @@ pub async fn edit(
if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) { if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) {
if user.id == member.id.user { if user.id == member.id.user {
permissions.throw_if_lacking_channel_permission(ChannelPermission::ChangeAvatar)?; permissions.throw_if_lacking_channel_permission(ChannelPermission::ChangeAvatar)?;
} else if data.remove.contains(&v0::FieldsMember::Avatar) {
permissions.throw_if_lacking_channel_permission(ChannelPermission::RemoveAvatars)?;
} else { } else {
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation))
} }
} }
@@ -83,7 +85,7 @@ pub async fn edit(
} }
if target_permissions.has_channel_permission(ChannelPermission::TimeoutMembers) { if target_permissions.has_channel_permission(ChannelPermission::TimeoutMembers) {
return Err(create_error!(IsElevated)) return Err(create_error!(IsElevated));
} }
} }
@@ -99,7 +101,7 @@ pub async fn edit(
} }
if data.voice_channel.is_some() && data.remove.contains(&FieldsMember::VoiceChannel) { if data.voice_channel.is_some() && data.remove.contains(&FieldsMember::VoiceChannel) {
return Err(create_error!(InvalidOperation)) return Err(create_error!(InvalidOperation));
} }
if data.voice_channel.is_some() || data.remove.contains(&FieldsMember::VoiceChannel) { if data.voice_channel.is_some() || data.remove.contains(&FieldsMember::VoiceChannel) {
@@ -217,8 +219,19 @@ pub async fn edit(
} }
}; };
set_user_moved_from_voice(&channel, new_voice_channel.id(), &target_user.id).await?; let new_user_voice_channel = UserVoiceChannel::from_channel(&new_voice_channel);
set_user_moved_to_voice(new_voice_channel.id(), &channel, &target_user.id).await?; let old_user_voice_channel = UserVoiceChannel {
id: channel.clone(),
server_id: new_user_voice_channel.server_id.clone(),
};
set_user_moved_from_voice(&channel, &new_user_voice_channel, &target_user.id).await?;
set_user_moved_to_voice(
new_voice_channel.id(),
&old_user_voice_channel,
&target_user.id,
)
.await?;
let mut query = perms(db, &target_user).channel(&new_voice_channel); let mut query = perms(db, &target_user).channel(&new_voice_channel);
let permissions = calculate_channel_permissions(&mut query).await; let permissions = calculate_channel_permissions(&mut query).await;
@@ -11,21 +11,21 @@ use rocket::{serde::json::Json, State};
/// ///
/// Retrieve a member. /// Retrieve a member.
#[openapi(tag = "Server Members")] #[openapi(tag = "Server Members")]
#[get("/<target>/members/<member>?<roles>")] #[get("/<server_id>/members/<member_id>?<roles>")]
pub async fn fetch( pub async fn fetch(
db: &State<Database>, db: &State<Database>,
user: User, user: User,
target: Reference<'_>, server_id: Reference<'_>,
member: Reference<'_>, member_id: Reference<'_>,
roles: Option<bool>, roles: Option<bool>,
) -> Result<Json<v0::MemberResponse>> { ) -> Result<Json<v0::MemberResponse>> {
let server = target.as_server(db).await?; let server = server_id.as_server(db).await?;
let mut query = DatabasePermissionQuery::new(db, &user).server(&server); let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
if !query.are_we_a_member().await { if !query.are_we_a_member().await {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
let member = member.as_member(db, &server.id).await?; let member = member_id.as_member(db, &server.id).await?;
if let Some(true) = roles { if let Some(true) = roles {
Ok(Json(v0::MemberResponse::MemberWithRoles { Ok(Json(v0::MemberResponse::MemberWithRoles {
roles: server roles: server
@@ -1,6 +1,9 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{get_user_voice_channel_in_server, remove_user_from_voice_channel, VoiceClient}, voice::{
get_user_voice_channel_in_server, remove_user_from_voice_channel, UserVoiceChannel,
VoiceClient,
},
Database, RemovalIntention, User, Database, RemovalIntention, User,
}; };
use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -12,21 +15,21 @@ use rocket_empty::EmptyResponse;
/// ///
/// Removes a member from the server. /// Removes a member from the server.
#[openapi(tag = "Server Members")] #[openapi(tag = "Server Members")]
#[delete("/<target>/members/<member>")] #[delete("/<server_id>/members/<member_id>")]
pub async fn kick( pub async fn kick(
db: &State<Database>, db: &State<Database>,
voice_client: &State<VoiceClient>, voice_client: &State<VoiceClient>,
user: User, user: User,
target: Reference<'_>, server_id: Reference<'_>,
member: Reference<'_>, member_id: Reference<'_>,
) -> Result<EmptyResponse> { ) -> Result<EmptyResponse> {
let server = target.as_server(db).await?; let server = server_id.as_server(db).await?;
if member.id == user.id { if member_id.id == user.id {
return Err(create_error!(CannotRemoveYourself)); return Err(create_error!(CannotRemoveYourself));
} }
if member.id == server.owner { if member_id.id == server.owner {
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation));
} }
@@ -35,7 +38,7 @@ pub async fn kick(
.await .await
.throw_if_lacking_channel_permission(ChannelPermission::KickMembers)?; .throw_if_lacking_channel_permission(ChannelPermission::KickMembers)?;
let member = member.as_member(db, &server.id).await?; let member = member_id.as_member(db, &server.id).await?;
if member.get_ranking(query.server_ref().as_ref().unwrap()) if member.get_ranking(query.server_ref().as_ref().unwrap())
<= query.get_member_rank().unwrap_or(i64::MIN) <= query.get_member_rank().unwrap_or(i64::MIN)
{ {
@@ -46,8 +49,16 @@ pub async fn kick(
.remove(db, &server, RemovalIntention::Kick, false) .remove(db, &server, RemovalIntention::Kick, false)
.await?; .await?;
if let Some(channel_id) = get_user_voice_channel_in_server(&target.id, &server.id).await? { if let Some(channel_id) = get_user_voice_channel_in_server(member_id.id, &server.id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, &target.id).await?; remove_user_from_voice_channel(
voice_client,
&UserVoiceChannel {
id: channel_id,
server_id: Some(server.id.clone()),
},
member_id.id,
)
.await?;
}; };
Ok(EmptyResponse) Ok(EmptyResponse)
@@ -1,3 +1,4 @@
use revolt_config::config;
use revolt_database::{Database, Member, Server, User}; use revolt_database::{Database, Member, Server, User};
use revolt_models::v0; use revolt_models::v0;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
@@ -20,6 +21,24 @@ pub async fn create_server(
return Err(create_error!(IsBot)); return Err(create_error!(IsBot));
} }
let config = config().await;
if !config
.features
.limits
.global
.restrict_server_creation
.is_empty()
&& !config
.features
.limits
.global
.restrict_server_creation
.contains(&user.id)
{
return Err(create_error!(CantCreateServers));
}
let data = data.into_inner(); let data = data.into_inner();
data.validate().map_err(|error| { data.validate().map_err(|error| {
create_error!(FailedValidation { create_error!(FailedValidation {
@@ -2,7 +2,7 @@ use revolt_database::{
util::reference::Reference, util::reference::Reference,
voice::{ voice::{
delete_voice_channel, get_user_voice_channel_in_server, remove_user_from_voice_channel, delete_voice_channel, get_user_voice_channel_in_server, remove_user_from_voice_channel,
VoiceClient, UserVoiceChannel, VoiceClient,
}, },
Database, RemovalIntention, User, Database, RemovalIntention, User,
}; };
@@ -29,13 +29,28 @@ pub async fn delete(
if server.owner == user.id { if server.owner == user.id {
for channel_id in &server.channels { for channel_id in &server.channels {
delete_voice_channel(voice_client, channel_id, Some(&server.id)).await?; delete_voice_channel(
voice_client,
&UserVoiceChannel {
id: channel_id.clone(),
server_id: Some(server.id.clone()),
},
)
.await?;
} }
server.delete(db).await server.delete(db).await
} else { } else {
if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? { if let Some(channel_id) = get_user_voice_channel_in_server(&user.id, &server.id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, &user.id).await?; remove_user_from_voice_channel(
voice_client,
&UserVoiceChannel {
id: channel_id,
server_id: Some(server.id.clone()),
},
&user.id,
)
.await?;
}; };
member member
+33 -1
View File
@@ -1,5 +1,6 @@
use std::collections::HashSet; use std::collections::HashSet;
use authifier::models::{totp::Totp, Account, ValidatedTicket};
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, File, PartialServer, User, Database, File, PartialServer, User,
@@ -7,7 +8,7 @@ use revolt_database::{
use revolt_models::v0; use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State}; use rocket::{serde::json::Json, Request, State};
use validator::Validate; use validator::Validate;
/// # Edit Server /// # Edit Server
@@ -17,9 +18,11 @@ use validator::Validate;
#[patch("/<target>", data = "<data>")] #[patch("/<target>", data = "<data>")]
pub async fn edit( pub async fn edit(
db: &State<Database>, db: &State<Database>,
account: Account,
user: User, user: User,
target: Reference<'_>, target: Reference<'_>,
data: Json<v0::DataEditServer>, data: Json<v0::DataEditServer>,
validated_ticket: Option<ValidatedTicket>,
) -> Result<Json<v0::Server>> { ) -> Result<Json<v0::Server>> {
let data = data.into_inner(); let data = data.into_inner();
data.validate().map_err(|error| { data.validate().map_err(|error| {
@@ -43,6 +46,7 @@ pub async fn edit(
&& data.flags.is_none() && data.flags.is_none()
&& data.analytics.is_none() && data.analytics.is_none()
&& data.discoverable.is_none() && data.discoverable.is_none()
&& data.owner.is_none()
&& data.remove.is_empty() && data.remove.is_empty()
{ {
return Ok(Json(server.into())); return Ok(Json(server.into()));
@@ -57,6 +61,17 @@ pub async fn edit(
permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageServer)?; permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageServer)?;
} }
// Check we are the server owner or privileged if changing sensitive fields
if data.owner.is_some() {
if user.id != server.owner && !user.privileged {
return Err(create_error!(NotOwner));
}
if validated_ticket.is_none() {
return Err(create_error!(InvalidCredentials));
}
}
// Check we are privileged if changing sensitive fields // Check we are privileged if changing sensitive fields
if (data.flags.is_some() /*|| data.nsfw.is_some()*/ || data.discoverable.is_some()) if (data.flags.is_some() /*|| data.nsfw.is_some()*/ || data.discoverable.is_some())
&& !user.privileged && !user.privileged
@@ -80,6 +95,7 @@ pub async fn edit(
// nsfw, // nsfw,
discoverable, discoverable,
analytics, analytics,
owner,
remove, remove,
} = data; } = data;
@@ -92,6 +108,7 @@ pub async fn edit(
// nsfw, // nsfw,
discoverable, discoverable,
analytics, analytics,
owner: owner.clone(),
..Default::default() ..Default::default()
}; };
@@ -146,6 +163,21 @@ pub async fn edit(
server.banner = partial.banner.clone(); server.banner = partial.banner.clone();
} }
// 5. Transfer ownership
if let Some(owner) = owner {
let owner_reference = Reference::from_unchecked(&owner);
// Check if member exists
owner_reference.as_member(db, &server.id).await?;
let owner_user = owner_reference.as_user(db).await?;
if owner_user.bot.is_some() {
return Err(create_error!(InvalidOperation));
}
server.owner = owner;
partial.owner = Some(server.owner.clone());
}
server server
.update(db, partial, remove.into_iter().map(Into::into).collect()) .update(db, partial, remove.into_iter().map(Into::into).collect())
.await?; .await?;
@@ -688,11 +688,23 @@ const LIGHT_ORANGE: &str = "#d9916d";
// for future use // for future use
// const WHITE: &str = "#c3e1c3"; // const WHITE: &str = "#c3e1c3";
fn shorten_text(text: &str, length: usize) -> String { fn shorten_single_line_text(text: &str, length: usize) -> String {
if text.len() < length { if text.contains('\n') {
text.to_string() let text = text.split('\n').next().unwrap();
format!("{}...", &text[..text.len().min(length) - 3])
} else if text.len() > length {
format!("{}...", &text[..length - 3])
} else { } else {
format!("{}...", &text[0..length]) text.to_string()
}
}
fn shorten_text(text: &str, length: usize) -> String {
if text.len() >= length {
format!("{}...", &text[..length - 3])
} else {
text.to_string()
} }
} }
@@ -823,7 +835,7 @@ pub async fn webhook_execute_github(
"[`{}`]({}) {} - {}", "[`{}`]({}) {} - {}",
&commit.id[0..=7], &commit.id[0..=7],
commit.url, commit.url,
shorten_text(&commit.message, 50), shorten_single_line_text(&commit.message, 50),
commit.author.name commit.author.name
) )
}) })
+19 -14
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-autumn" name = "revolt-autumn"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
publish = false publish = false
@@ -17,6 +17,7 @@ jxl-oxide = "0.8.1"
kamadak-exif = "0.5.4" kamadak-exif = "0.5.4"
# revolt_little_exif = "0.5.1" # revolt_little_exif = "0.5.1"
image = { version = "0.25.2" } # avif encode requires dav1d system library: features = ["avif-native"] image = { version = "0.25.2" } # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = "0.1.0"
# File processing # File processing
revolt_clamav-client = { version = "0.1.5" } revolt_clamav-client = { version = "0.1.5" }
@@ -39,29 +40,33 @@ serde = { version = "1.0", features = ["derive"] }
# Async runtime # Async runtime
tokio = { version = "1.0", features = ["full"] } tokio = { version = "1.0", features = ["full"] }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Core crates # Core crates
revolt-files = { version = "0.11.1", path = "../../core/files" } revolt-files = { version = "0.12.0", path = "../../core/files" }
revolt-config = { version = "0.11.1", path = "../../core/config" } revolt-config = { version = "0.12.0", path = "../../core/config" }
revolt-database = { version = "0.11.1", path = "../../core/database", features = [ revolt-database = { version = "0.12.0", path = "../../core/database", features = [
"axum-impl", "axum-impl",
] } ] }
revolt-result = { version = "0.11.1", path = "../../core/result", features = [ revolt-result = { version = "0.12.0", path = "../../core/result", features = [
"utoipa", "utoipa",
"axum", "axum",
] } ] }
revolt-ratelimits = { version = "0.11.1", path = "../../core/ratelimits", features = ["axum"] } revolt-ratelimits = { version = "0.12.0", path = "../../core/ratelimits", features = ["axum"] }
# Axum / web server # Axum / web server
tempfile = "3.12.0" tempfile = "3.12.0"
axum-macros = "0.4.1" axum-macros = { workspace = true }
axum_typed_multipart = "0.12.1" axum_typed_multipart = { workspace = true }
axum = { version = "0.7.5", features = ["multipart"] } axum = { workspace = true }
tower-http = { version = "0.5.2", features = ["cors"] } tower-http = { workspace = true }
# OpenAPI & documentation generation # OpenAPI & documentation generation
utoipa-scalar = { version = "0.1.0", features = ["axum"] } utoipa-scalar = { version = "0.1.0", features = ["axum"] }
utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] } utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
# OpenTelemetry
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
opentelemetry-otlp = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
+4 -44
View File
@@ -27,7 +27,6 @@ use utoipa::ToSchema;
use crate::{exif::strip_metadata, metadata::generate_metadata, mime_type::determine_mime_type, AppState}; use crate::{exif::strip_metadata, metadata::generate_metadata, mime_type::determine_mime_type, AppState};
/// Build the API router
pub async fn router() -> Router<AppState> { pub async fn router() -> Router<AppState> {
let config = config().await; let config = config().await;
@@ -58,8 +57,6 @@ pub async fn router() -> Router<AppState> {
} }
lazy_static! { lazy_static! {
/// Short-lived file cache to allow us to populate different CDN regions without increasing bandwidth to S3 provider
/// Uploads will also be stored here to prevent immediately queued downloads from doing the entire round-trip
static ref S3_CACHE: moka::future::Cache<String, Result<Vec<u8>>> = moka::future::Cache::builder() static ref S3_CACHE: moka::future::Cache<String, Result<Vec<u8>>> = moka::future::Cache::builder()
.weigher(|_key, value: &Result<Vec<u8>>| -> u32 { .weigher(|_key, value: &Result<Vec<u8>>| -> u32 {
std::mem::size_of::<Result<Vec<u8>>>() as u32 + if let Ok(vec) = value { std::mem::size_of::<Result<Vec<u8>>>() as u32 + if let Ok(vec) = value {
@@ -76,7 +73,6 @@ lazy_static! {
.build(); .build();
} }
/// Retrieve hash information and file data by given hash
async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> { async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> {
if let Some(data) = S3_CACHE.get(&hash.id).await { if let Some(data) = S3_CACHE.get(&hash.id).await {
data data
@@ -87,17 +83,15 @@ async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> {
} }
} }
/// Successful root response
#[derive(Serialize, Debug, ToSchema)] #[derive(Serialize, Debug, ToSchema)]
pub struct RootResponse { pub struct RootResponse {
autumn: &'static str, autumn: &'static str,
version: &'static str, version: &'static str,
} }
/// Capture crate version from Cargo
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Root response from service /// Get information about the service
#[utoipa::path( #[utoipa::path(
get, get,
path = "/", path = "/",
@@ -112,7 +106,6 @@ async fn root() -> Json<RootResponse> {
}) })
} }
/// Empty handler for OPTIONS routes
async fn options() {} async fn options() {}
/// Available tags to upload to /// Available tags to upload to
@@ -176,28 +169,19 @@ async fn upload_file(
Path(tag): Path<Tag>, Path(tag): Path<Tag>,
TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>, TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>,
) -> Result<Json<UploadResponse>> { ) -> Result<Json<UploadResponse>> {
// Fetch configuration let now = Instant::now();
let config = config().await; let config = config().await;
// Keep track of processing time
let now = Instant::now();
// Extract the filename, or give it a generic name
let filename = file.metadata.file_name.unwrap_or("unnamed-file".to_owned()); let filename = file.metadata.file_name.unwrap_or("unnamed-file".to_owned());
// Load file to memory
let mut buf = Vec::<u8>::new(); let mut buf = Vec::<u8>::new();
report_internal_error!(file.contents.read_to_end(&mut buf))?; report_internal_error!(file.contents.read_to_end(&mut buf))?;
// Take note of original file size
let original_file_size = buf.len(); let original_file_size = buf.len();
// Ensure the file is not empty
if original_file_size < config.files.limit.min_file_size { if original_file_size < config.files.limit.min_file_size {
return Err(create_error!(FileTooSmall)); return Err(create_error!(FileTooSmall));
} }
// Get user's file upload limits
let limits = user.limits().await; let limits = user.limits().await;
let size_limit = *limits let size_limit = *limits
.file_upload_size_limit .file_upload_size_limit
@@ -208,24 +192,19 @@ async fn upload_file(
return Err(create_error!(FileTooLarge { max: size_limit })); return Err(create_error!(FileTooLarge { max: size_limit }));
} }
// Generate sha256 hash
let original_hash = { let original_hash = {
let mut hasher = sha2::Sha256::new(); let mut hasher = sha2::Sha256::new();
hasher.update(&buf); hasher.update(&buf);
hasher.finalize() hasher.finalize()
}; };
// Generate an ID for this file
let id = if matches!(tag, Tag::emojis) { let id = if matches!(tag, Tag::emojis) {
ulid::Ulid::new().to_string() ulid::Ulid::new().to_string()
} else { } else {
nanoid::nanoid!(42) nanoid::nanoid!(42)
}; };
// Determine the mime type for the file
let mime_type = determine_mime_type(&mut file.contents, &buf, &filename); let mime_type = determine_mime_type(&mut file.contents, &buf, &filename);
// Check blocklist for mime type
if config if config
.files .files
.blocked_mime_types .blocked_mime_types
@@ -235,15 +214,11 @@ async fn upload_file(
return Err(create_error!(FileTypeNotAllowed)); return Err(create_error!(FileTypeNotAllowed));
} }
// Determine metadata for the file
let metadata = generate_metadata(&file.contents, mime_type); let metadata = generate_metadata(&file.contents, mime_type);
// Block non-images for non-attachment uploads
if !matches!(tag, Tag::attachments) && !matches!(metadata, Metadata::Image { .. }) { if !matches!(tag, Tag::attachments) && !matches!(metadata, Metadata::Image { .. }) {
return Err(create_error!(FileTypeNotAllowed)); return Err(create_error!(FileTypeNotAllowed));
} }
// Find an existing hash and use that if possible
let file_hash_exists = if let Ok(file_hash) = db let file_hash_exists = if let Ok(file_hash) = db
.fetch_attachment_hash(&format!("{original_hash:02x}")) .fetch_attachment_hash(&format!("{original_hash:02x}"))
.await .await
@@ -266,10 +241,8 @@ async fn upload_file(
false false
}; };
// Strip metadata
let (buf, metadata) = strip_metadata(file.contents, buf, metadata, mime_type).await?; let (buf, metadata) = strip_metadata(file.contents, buf, metadata, mime_type).await?;
// Virus scan files if ClamAV is configured
if matches!(metadata, Metadata::File) if matches!(metadata, Metadata::File)
&& (config.files.scan_mime_types.is_empty() && (config.files.scan_mime_types.is_empty()
|| config.files.scan_mime_types.iter().any(|v| v == mime_type)) || config.files.scan_mime_types.iter().any(|v| v == mime_type))
@@ -278,7 +251,6 @@ async fn upload_file(
return Err(create_error!(InternalError)); return Err(create_error!(InternalError));
} }
// Print file information for debug purposes
let new_file_size = buf.len() + AUTHENTICATION_TAG_SIZE_BYTES; let new_file_size = buf.len() + AUTHENTICATION_TAG_SIZE_BYTES;
let processed_hash = { let processed_hash = {
let mut hasher = sha2::Sha256::new(); let mut hasher = sha2::Sha256::new();
@@ -290,7 +262,6 @@ async fn upload_file(
tracing::info!("Received file {filename}\nOriginal hash: {original_hash:02x}\nOriginal size: {original_file_size} bytes\nMime type: {mime_type}\nMetadata: {metadata:?}\nProcessed file size: {new_file_size} bytes ({:.2}%).\nProcessed hash: {processed_hash:02x}\nProcessing took {time_to_process:?}", process_ratio * 100.0); tracing::info!("Received file {filename}\nOriginal hash: {original_hash:02x}\nOriginal size: {original_file_size} bytes\nMime type: {mime_type}\nMetadata: {metadata:?}\nProcessed file size: {new_file_size} bytes ({:.2}%).\nProcessed hash: {processed_hash:02x}\nProcessing took {time_to_process:?}", process_ratio * 100.0);
// Create hash entry in database
let file_hash = FileHash { let file_hash = FileHash {
id: format!("{original_hash:02x}"), id: format!("{original_hash:02x}"),
processed_hash: format!("{processed_hash:02x}"), processed_hash: format!("{processed_hash:02x}"),
@@ -306,21 +277,17 @@ async fn upload_file(
size: new_file_size as isize, size: new_file_size as isize,
}; };
// Add attachment hash if it doesn't exist
if !file_hash_exists { if !file_hash_exists {
db.insert_attachment_hash(&file_hash).await?; db.insert_attachment_hash(&file_hash).await?;
} }
// Upload the file to S3 and commit nonce to database
let upload_start = Instant::now(); let upload_start = Instant::now();
let nonce = upload_to_s3(&file_hash.bucket_id, &file_hash.id, &buf).await?; let nonce = upload_to_s3(&file_hash.bucket_id, &file_hash.id, &buf).await?;
db.set_attachment_hash_nonce(&file_hash.id, &nonce).await?; db.set_attachment_hash_nonce(&file_hash.id, &nonce).await?;
// Debug information
let time_to_upload = Instant::now() - upload_start; let time_to_upload = Instant::now() - upload_start;
tracing::info!("Took {time_to_upload:?} to upload {new_file_size} bytes to S3."); tracing::info!("Took {time_to_upload:?} to upload {new_file_size} bytes to S3.");
// Finally, create the file and return its ID
let tag: &'static str = tag.into(); let tag: &'static str = tag.into();
db.insert_attachment(&file_hash.into_file(id.clone(), tag.to_owned(), filename, user.id)) db.insert_attachment(&file_hash.into_file(id.clone(), tag.to_owned(), filename, user.id))
.await?; .await?;
@@ -368,21 +335,19 @@ async fn fetch_preview(
let tag_str: &'static str = tag.clone().into(); let tag_str: &'static str = tag.clone().into();
let file = db.fetch_attachment(tag_str, &file_id).await?; let file = db.fetch_attachment(tag_str, &file_id).await?;
// Ignore deleted files
if file.deleted.is_some_and(|v| v) { if file.deleted.is_some_and(|v| v) {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
// Ignore files that haven't been attached
if file.used_for.is_none() { if file.used_for.is_none() {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
let hash = file.as_hash(&db).await?; let hash = file.as_hash(&db).await?;
let is_animated = hash.content_type == "image/gif"; // TODO: extract this data from files let is_animated = matches!(hash.metadata, Metadata::Image { animated: true, .. });
// Only process image files and don't process GIFs if not avatar or icon // Process GIFs if avatar or icon
if !matches!(hash.metadata, Metadata::Image { .. }) if !matches!(hash.metadata, Metadata::Image { .. })
|| (is_animated && !matches!(tag, Tag::avatars | Tag::icons)) || (is_animated && !matches!(tag, Tag::avatars | Tag::icons))
{ {
@@ -391,10 +356,8 @@ async fn fetch_preview(
); );
} }
// Original image data
let data = retrieve_file_by_hash(&hash).await?; let data = retrieve_file_by_hash(&hash).await?;
// Read image and create thumbnail
let data = create_thumbnail( let data = create_thumbnail(
decode_image(&mut Cursor::new(data), &file.content_type)?, decode_image(&mut Cursor::new(data), &file.content_type)?,
tag_str, tag_str,
@@ -436,17 +399,14 @@ async fn fetch_file(
let tag: &'static str = tag.clone().into(); let tag: &'static str = tag.clone().into();
let file = db.fetch_attachment(tag, &file_id).await?; let file = db.fetch_attachment(tag, &file_id).await?;
// Ignore deleted files
if file.deleted.is_some_and(|v| v) { if file.deleted.is_some_and(|v| v) {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
// Ignore files that haven't been attached
if file.used_for.is_none() { if file.used_for.is_none() {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
// Ensure filename is correct
if file_name != file.filename { if file_name != file.filename {
if file_name == "original" { if file_name == "original" {
return Ok( return Ok(
-2
View File
@@ -3,7 +3,6 @@ use std::time::Duration;
use revolt_config::{config, report_internal_error}; use revolt_config::{config, report_internal_error};
use revolt_result::Result; use revolt_result::Result;
/// Initialise ClamAV
pub async fn init() { pub async fn init() {
let config = config().await; let config = config().await;
@@ -32,7 +31,6 @@ pub async fn init() {
} }
} }
/// Scan for malware
pub async fn is_malware(buf: &[u8]) -> Result<bool> { pub async fn is_malware(buf: &[u8]) -> Result<bool> {
let config = config().await; let config = config().await;
if config.files.clamd_host.is_empty() { if config.files.clamd_host.is_empty() {
+22 -30
View File
@@ -8,7 +8,6 @@ use revolt_result::{create_error, Result};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio::process::Command; use tokio::process::Command;
/// Strip EXIF data from given file and produce new file and metadata
pub async fn strip_metadata( pub async fn strip_metadata(
file: NamedTempFile, file: NamedTempFile,
buf: Vec<u8>, buf: Vec<u8>,
@@ -16,7 +15,12 @@ pub async fn strip_metadata(
mime: &str, mime: &str,
) -> Result<(Vec<u8>, Metadata)> { ) -> Result<(Vec<u8>, Metadata)> {
match &metadata { match &metadata {
Metadata::Image { width, height } => match mime { Metadata::Image {
width,
height,
thumbhash,
animated,
} => match mime {
// // little_exif does not appear to parse JPEGs correctly? had 2/2 files fail // // little_exif does not appear to parse JPEGs correctly? had 2/2 files fail
// "image/jpeg" | "image/png" => { // "image/jpeg" | "image/png" => {
// // use little_exif to strip metadata except for orientation and colour profile // // use little_exif to strip metadata except for orientation and colour profile
@@ -37,20 +41,16 @@ pub async fn strip_metadata(
// } // }
// Apply orientation manually & strip all other EXIF data // Apply orientation manually & strip all other EXIF data
"image/jpeg" | "image/png" | "image/avif" | "image/tiff" => { "image/jpeg" | "image/png" | "image/avif" | "image/tiff" => {
// Create a reader
let mut cursor = Cursor::new(buf); let mut cursor = Cursor::new(buf);
// Decode the image
let image = report_internal_error!(report_internal_error!(ImageReader::new( let image = report_internal_error!(report_internal_error!(ImageReader::new(
&mut cursor &mut cursor
) )
.with_guessed_format())? .with_guessed_format())?
.decode()); .decode());
// Reset read position
cursor.set_position(0); cursor.set_position(0);
// Extract orientation data
let exif_reader = Reader::new(); let exif_reader = Reader::new();
let rotation = match exif_reader.read_from_container(&mut cursor) { let rotation = match exif_reader.read_from_container(&mut cursor) {
Ok(exif) => match exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) { Ok(exif) => match exif.get_field(exif::Tag::Orientation, exif::In::PRIMARY) {
@@ -60,12 +60,10 @@ pub async fn strip_metadata(
_ => 0, _ => 0,
}; };
// Create a buffer to write to
let mut bytes: Vec<u8> = Vec::new(); let mut bytes: Vec<u8> = Vec::new();
let mut writer = Cursor::new(&mut bytes); let mut writer = Cursor::new(&mut bytes);
// Apply the EXIF rotation // https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
// See https://jdhao.github.io/2019/07/31/image_rotation_exif_info/
report_internal_error!(match &rotation { report_internal_error!(match &rotation {
2 => image?.fliph(), 2 => image?.fliph(),
3 => image?.rotate180(), 3 => image?.rotate180(),
@@ -87,24 +85,28 @@ pub async fn strip_metadata(
}, },
))?; ))?;
// Calculate dimensions after rotation.
let (width, height) = match &rotation { let (width, height) = match &rotation {
2 | 4 | 5 | 7 => (*height, *width), 2 | 4 | 5 | 7 => (*height, *width),
_ => (*width, *height), _ => (*width, *height),
}; };
Ok((bytes, Metadata::Image { width, height })) Ok((
bytes,
Metadata::Image {
width,
height,
thumbhash: thumbhash.clone(),
animated: *animated,
},
))
} }
// JXLs store EXIF data but we don't have the ability to write them // TODO: JXLs store EXIF data but we don't have the ability to write them
"image/jxl" => Ok((buf, metadata)), "image/jxl" => Ok((buf, metadata)),
// All other images that cannot store EXIF data // assume all other images that cannot store EXIF data
_ => Ok((buf, metadata)), _ => Ok((buf, metadata)),
}, },
// Use ffmpeg to copy video stream and probe new metadata
Metadata::Video { .. } => match mime { Metadata::Video { .. } => match mime {
// Strip EXIF data by copying video stream
"video/mp4" | "video/webm" | "video/quicktime" => { "video/mp4" | "video/webm" | "video/quicktime" => {
// Pick the correct file format for ffmpeg
let ext = match mime { let ext = match mime {
"video/mp4" => "mp4", "video/mp4" => "mp4",
"video/webm" => "webm", "video/webm" => "webm",
@@ -112,30 +114,22 @@ pub async fn strip_metadata(
_ => unreachable!(), _ => unreachable!(),
}; };
// Temporary output file
let mut out_file = report_internal_error!(NamedTempFile::new())?; let mut out_file = report_internal_error!(NamedTempFile::new())?;
// Process the file with ffmpeg
report_internal_error!( report_internal_error!(
Command::new("ffmpeg") Command::new("ffmpeg")
.args([ .args([
// Overwrite the temporary file
"-y", "-y",
// Read original uploaded file
"-i", "-i",
file.path().to_str().ok_or(create_error!(InternalError))?, file.path().to_str().ok_or(create_error!(InternalError))?,
// Strip any metadata "-map_metadata", // strip metadata
"-map_metadata",
"-1", "-1",
// Copy video / audio data to new file "-c:v", // just copy the streams
"-c:v",
"copy", "copy",
"-c:a", "-c:a",
"copy", "copy",
// Select correct file format
"-f", "-f",
ext, ext,
// Save to new temporary file
out_file out_file
.path() .path()
.to_str() .to_str()
@@ -145,19 +139,17 @@ pub async fn strip_metadata(
.await .await
)?; )?;
// Probe the file again
let metadata = crate::metadata::generate_metadata(&out_file, mime); let metadata = crate::metadata::generate_metadata(&out_file, mime);
// Read the file from disk
let mut buf = Vec::<u8>::new(); let mut buf = Vec::<u8>::new();
report_internal_error!(out_file.read_to_end(&mut buf))?; report_internal_error!(out_file.read_to_end(&mut buf))?;
Ok((buf, metadata)) Ok((buf, metadata))
} }
// Assume all other video formats cannot store EXIF data // assume all other video formats cannot store EXIF data
_ => Ok((buf, metadata)), _ => Ok((buf, metadata)),
}, },
// all other file types don't store EXIF data // assume all other file types don't store EXIF data
_ => Ok((buf, metadata)), _ => Ok((buf, metadata)),
} }
} }
+72 -8
View File
@@ -6,6 +6,7 @@ use axum_macros::FromRef;
use revolt_database::{Database, DatabaseInfo}; use revolt_database::{Database, DatabaseInfo};
use revolt_ratelimits::axum as ratelimiter; use revolt_ratelimits::axum as ratelimiter;
use tokio::net::TcpListener; use tokio::net::TcpListener;
use tower_http::trace::TraceLayer;
use utoipa::{ use utoipa::{
openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, openapi::security::{ApiKey, ApiKeyValue, SecurityScheme},
Modify, OpenApi, Modify, OpenApi,
@@ -27,13 +28,32 @@ struct AppState {
#[tokio::main] #[tokio::main]
async fn main() -> Result<(), std::io::Error> { async fn main() -> Result<(), std::io::Error> {
// Configure logging and environment let logger_provider = init_logs();
revolt_config::configure!(files);
// Wait for ClamAV let otel_layer = OpenTelemetryTracingBridge::new(&logger_provider);
let filter_otel = EnvFilter::new("info")
.add_directive("hyper=off".parse().unwrap())
.add_directive("tonic=off".parse().unwrap())
.add_directive("h2=off".parse().unwrap())
.add_directive("reqwest=off".parse().unwrap());
let otel_layer = otel_layer.with_filter(filter_otel);
let filter_fmt = EnvFilter::new("info");
let fmt_layer = tracing_subscriber::fmt::layer()
.with_thread_names(true)
.with_filter(filter_fmt);
tracing_subscriber::registry()
.with(otel_layer)
.with(fmt_layer)
.init();
revolt_config::configure!(files);
clamav::init().await; clamav::init().await;
// Configure API schema
#[derive(OpenApi)] #[derive(OpenApi)]
#[openapi( #[openapi(
modifiers(&SecurityAddon), modifiers(&SecurityAddon),
@@ -76,7 +96,6 @@ async fn main() -> Result<(), std::io::Error> {
} }
} }
// Connect to the database
let db = DatabaseInfo::Auto.connect().await.unwrap(); let db = DatabaseInfo::Auto.connect().await.unwrap();
let ratelimits = ratelimiter::RatelimitStorage::new(ratelimits::AutumnRatelimits); let ratelimits = ratelimiter::RatelimitStorage::new(ratelimits::AutumnRatelimits);
@@ -85,7 +104,6 @@ async fn main() -> Result<(), std::io::Error> {
ratelimit_storage: ratelimits, ratelimit_storage: ratelimits,
}; };
// Configure Axum and router
let app = Router::new() let app = Router::new()
.merge(Scalar::with_url("/scalar", ApiDoc::openapi())) .merge(Scalar::with_url("/scalar", ApiDoc::openapi()))
.nest("/", api::router().await) .nest("/", api::router().await)
@@ -94,10 +112,56 @@ async fn main() -> Result<(), std::io::Error> {
state.clone(), state.clone(),
ratelimiter::ratelimit_middleware, ratelimiter::ratelimit_middleware,
)) ))
.layer(TraceLayer::new_for_http())
.with_state(state); .with_state(state);
// Configure TCP listener and bind
let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14704)); let address = SocketAddr::from((Ipv4Addr::UNSPECIFIED, 14704));
let listener = TcpListener::bind(&address).await?; let listener = TcpListener::bind(&address).await?;
axum::serve(listener, app.into_make_service()).await axum::serve(listener, app.into_make_service()).await?;
if let Err(e) = logger_provider.shutdown() {
panic!("logger provider failed to shut down");
}
Ok(())
}
use opentelemetry::trace::{TraceContextExt, Tracer};
use opentelemetry::KeyValue;
use opentelemetry::{global, InstrumentationScope};
use opentelemetry_appender_tracing::layer::OpenTelemetryTracingBridge;
use opentelemetry_otlp::{LogExporter, MetricExporter, Protocol, SpanExporter, WithExportConfig};
use opentelemetry_sdk::logs::SdkLoggerProvider;
use opentelemetry_sdk::metrics::SdkMeterProvider;
use opentelemetry_sdk::trace::SdkTracerProvider;
use opentelemetry_sdk::Resource;
use std::error::Error;
use std::sync::OnceLock;
use tracing::info;
use tracing_subscriber::prelude::*;
use tracing_subscriber::EnvFilter;
fn get_resource() -> Resource {
static RESOURCE: OnceLock<Resource> = OnceLock::new();
RESOURCE
.get_or_init(|| {
Resource::builder()
.with_service_name("basic-otlp-example-grpc")
.build()
})
.clone()
}
fn init_logs() -> SdkLoggerProvider {
let exporter = LogExporter::builder()
.with_http()
.with_endpoint("http://localhost:19428/insert/opentelemetry/v1/logs")
.with_protocol(Protocol::HttpBinary)
.build()
.expect("Failed to create log exporter");
SdkLoggerProvider::builder()
.with_resource(get_resource())
.with_batch_exporter(exporter)
.build()
} }
+13 -3
View File
@@ -1,7 +1,8 @@
use std::io::Cursor; use std::io::Cursor;
use image::{GenericImageView, ImageError, ImageReader};
use revolt_database::Metadata; use revolt_database::Metadata;
use revolt_files::{image_size, video_size}; use revolt_files::{image_size, is_animated, video_size};
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
/// Intersection of what infer can detect and what image-rs supports /// Intersection of what infer can detect and what image-rs supports
@@ -19,13 +20,23 @@ static SUPPORTED_IMAGE_MIME: [&str; 9] = [
"image/webp", "image/webp",
]; ];
/// Generate metadata from file, using mime type as a hint
pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata { pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
if SUPPORTED_IMAGE_MIME.contains(&mime_type) { if SUPPORTED_IMAGE_MIME.contains(&mime_type) {
image_size(f) image_size(f)
.map(|(width, height)| Metadata::Image { .map(|(width, height)| Metadata::Image {
width: width as isize, width: width as isize,
height: height as isize, height: height as isize,
thumbhash: ImageReader::open(f)
.and_then(|r| r.with_guessed_format())
.map_err(ImageError::from)
.and_then(|r| r.decode())
.map(|img| img.thumbnail(100, 100))
.map(|img| (img.dimensions(), img.to_rgba8().into_raw()))
.map(|((width, height), rgba)| {
thumbhash::rgba_to_thumb_hash(width as usize, height as usize, &rgba)
})
.ok(),
animated: is_animated(f, mime_type).unwrap_or(false),
}) })
.unwrap_or_default() .unwrap_or_default()
} else if mime_type.starts_with("video/") { } else if mime_type.starts_with("video/") {
@@ -44,7 +55,6 @@ pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata {
} }
} }
/// Subroutine to ensure data isn't corrupted
pub fn validate_from_metadata( pub fn validate_from_metadata(
reader: Cursor<Vec<u8>>, reader: Cursor<Vec<u8>>,
metadata: Metadata, metadata: Metadata,
-4
View File
@@ -1,15 +1,12 @@
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
/// Determine the mime type of the given temporary file and filename
pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -> &'static str { pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -> &'static str {
// Force certain extensions into particular mime types
if file_name.to_lowercase().ends_with(".apk") { if file_name.to_lowercase().ends_with(".apk") {
return "application/vnd.android.package-archive"; return "application/vnd.android.package-archive";
} else if file_name.to_lowercase().ends_with(".exe") { } else if file_name.to_lowercase().ends_with(".exe") {
return "application/vnd.microsoft.portable-executable"; return "application/vnd.microsoft.portable-executable";
} }
// Use magic signatures to determine mime type
let kind = infer::get_from_path(f.path()).expect("file read successfully"); let kind = infer::get_from_path(f.path()).expect("file read successfully");
let mime_type = if let Some(kind) = kind { let mime_type = if let Some(kind) = kind {
kind.mime_type() kind.mime_type()
@@ -17,7 +14,6 @@ pub fn determine_mime_type(f: &mut NamedTempFile, buf: &[u8], file_name: &str) -
"application/octet-stream" "application/octet-stream"
}; };
// See if the file is actually just plain Unicode/ASCII text
if mime_type == "application/octet-stream" && simdutf8::basic::from_utf8(buf).is_ok() { if mime_type == "application/octet-stream" && simdutf8::basic::from_utf8(buf).is_ok() {
if file_name.to_lowercase().ends_with(".svg") { if file_name.to_lowercase().ends_with(".svg") {
return "image/svg+xml"; return "image/svg+xml";
+7 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-gifbox" name = "revolt-gifbox"
version = "0.11.1" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
publish = false publish = false
@@ -17,19 +17,19 @@ tokio = { version = "1.0", features = ["full"] }
reqwest = { version = "0.12", features = ["json"] } reqwest = { version = "0.12", features = ["json"] }
# Core crates # Core crates
revolt-config = { version = "0.11.1", path = "../../core/config" } revolt-config = { version = "0.12.0", path = "../../core/config" }
revolt-models = { version = "0.11.1", path = "../../core/models" } revolt-models = { version = "0.12.0", path = "../../core/models" }
revolt-result = { version = "0.11.1", path = "../../core/result", features = [ revolt-result = { version = "0.12.0", path = "../../core/result", features = [
"utoipa", "utoipa",
"axum", "axum",
] } ] }
revolt-coalesced = { version = "0.11.1", path = "../../core/coalesced", features = [ revolt-coalesced = { version = "0.12.0", path = "../../core/coalesced", features = [
"queue", "queue",
] } ] }
revolt-database = { version = "0.11.1", path = "../../core/database", features = [ revolt-database = { version = "0.12.0", path = "../../core/database", features = [
"axum-impl", "axum-impl",
] } ] }
revolt-ratelimits = { version = "0.11.1", path = "../../core/ratelimits", features = [ revolt-ratelimits = { version = "0.12.0", path = "../../core/ratelimits", features = [
"axum", "axum",
] } ] }
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more