diff --git a/.github/ISSUE_TEMPLATE/bug.yml b/.github/ISSUE_TEMPLATE/bug.yml new file mode 100644 index 00000000..867978dd --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug.yml @@ -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. diff --git a/.github/workflows/git-town.yml b/.github/workflows/git-town.yml index 03b9d9a0..678b60c1 100644 --- a/.github/workflows/git-town.yml +++ b/.github/workflows/git-town.yml @@ -1,15 +1,18 @@ +# DO NOT EDIT DIRECTLY IN REPOSITORY +# Managed in Terraform templates + name: Git Town on: pull_request: - branches: - - '**' jobs: git-town: name: Display the branch stack runs-on: ubuntu-slim + if: ${{ github.event.pull_request.head.repo.full_name == github.repository && !startsWith(github.head_ref, 'release-please--') }} + permissions: contents: read pull-requests: write @@ -17,3 +20,4 @@ jobs: steps: - uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1 - uses: stoatchat/action-git-town@4bc5c942e4603bffa0806b51d5fe5f0bc5deb0ac + continue-on-error: true \ No newline at end of file diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index 7e68e112..63d9a88e 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -45,8 +45,9 @@ jobs: if: ${{ steps.rp.outputs.prs_created == 'true' || steps.rp.outputs.prs_updated == 'true' }} env: GH_TOKEN: ${{ steps.app-token.outputs.token }} + RP_PR_OUTPUT_JSON: ${{ steps.rp.outputs.prs }} run: | - PR_NUMBER=$(echo '${{ steps.rp.outputs.prs }}' | jq -r '.[0].number') + PR_NUMBER=$(echo "$RP_PR_OUTPUT_JSON" | jq -r '.[0].number') gh pr checkout "$PR_NUMBER" cargo update -w diff --git a/.github/workflows/release-webhook.yml b/.github/workflows/release-webhook.yml index 58a1d419..3996b1f8 100644 --- a/.github/workflows/release-webhook.yml +++ b/.github/workflows/release-webhook.yml @@ -1,3 +1,6 @@ +# DO NOT EDIT DIRECTLY IN REPOSITORY +# Managed in Terraform templates + name: Release Webhook on: @@ -12,8 +15,12 @@ jobs: steps: - name: Send release notification webhook + env: + TAG_NAME: ${{ github.event.release.tag_name }} + REPOSITORY: ${{ github.repository }} + WEBHOOK_URL: ${{ secrets.STOAT_WEBHOOK_UPDATES_URL }} run: | - RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${{ github.event.release.tag_name }}" - curl -X POST "${{ secrets.STOAT_WEBHOOK_UPDATES_URL }}" \ + RELEASE_URL="https://github.com/${REPOSITORY}/releases/tag/${TAG_NAME}" + curl -X POST "$WEBHOOK_URL" \ -H "Content-Type: application/json" \ - -d "{\"content\": \"$RELEASE_URL\"}" + -d "{\"content\": \"$RELEASE_URL\"}" \ No newline at end of file diff --git a/.github/workflows/validate-pr-title.yml b/.github/workflows/validate-pr-title.yml index 6af09303..1374d58c 100644 --- a/.github/workflows/validate-pr-title.yml +++ b/.github/workflows/validate-pr-title.yml @@ -1,3 +1,6 @@ +# DO NOT EDIT DIRECTLY IN REPOSITORY +# Managed in Terraform templates + name: "Lint PR" on: @@ -15,6 +18,6 @@ jobs: permissions: pull-requests: read steps: - - uses: amannn/action-semantic-pull-request@v6 + - uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 7bff16bc..53d4a3f2 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.4" + ".": "0.12.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 22da6e66..7153a56f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,118 @@ # 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) + + +### Bug Fixes + +* bots in multiple voice channel logic ([#544](https://github.com/stoatchat/stoatchat/issues/544)) ([94cb916](https://github.com/stoatchat/stoatchat/commit/94cb916231b9b8befb2e94065917ff40815bec52)) + +## [0.11.0](https://github.com/stoatchat/stoatchat/compare/v0.10.3...v0.11.0) (2026-02-10) + + +### Features + +* appeal to the almighty Spamhaus ([#524](https://github.com/stoatchat/stoatchat/issues/524)) ([5132270](https://github.com/stoatchat/stoatchat/commit/5132270f2edd6df25ce414daa42ed1b2aa6fa7a9)) + +## [0.10.3](https://github.com/stoatchat/stoatchat/compare/v0.10.2...v0.10.3) (2026-02-07) + + +### Bug Fixes + +* update `Revolt` -> `Stoat` in email titles/desc. ([#508](https://github.com/stoatchat/stoatchat/issues/508)) ([84483ce](https://github.com/stoatchat/stoatchat/commit/84483cee7af3e5dfa16f7fe13e334c4d9f5abd60)) + +## [0.10.2](https://github.com/stoatchat/stoatchat/compare/v0.10.1...v0.10.2) (2026-01-25) + + +### Bug Fixes + +* thumbnailification requires rgb8/rgba8 ([#505](https://github.com/stoatchat/stoatchat/issues/505)) ([413aa04](https://github.com/stoatchat/stoatchat/commit/413aa04dcaf8bff3935ed1e5f31432e11a03ce6f)) + +## [0.10.1](https://github.com/stoatchat/stoatchat/compare/v0.10.0...v0.10.1) (2026-01-25) + + +### Bug Fixes + +* use Rust 1.92.0 for Docker build ([#503](https://github.com/stoatchat/stoatchat/issues/503)) ([98da8a2](https://github.com/stoatchat/stoatchat/commit/98da8a28a0aa2fee4e8eee1d86bd7c49e3187477)) + +## [0.10.0](https://github.com/stoatchat/stoatchat/compare/v0.9.4...v0.10.0) (2026-01-25) + + +### Features + +* allow kicking members from voice channels ([#495](https://github.com/stoatchat/stoatchat/issues/495)) ([0dc5442](https://github.com/stoatchat/stoatchat/commit/0dc544249825a49c793309edee5ec1838458a6da)) +* repository architecture for files crate w. added tests ([#498](https://github.com/stoatchat/stoatchat/issues/498)) ([01ded20](https://github.com/stoatchat/stoatchat/commit/01ded209c62208fc906d6aab9b08c04e860e10ef)) + + +### Bug Fixes + +* expose ratelimit headers via cors ([#496](https://github.com/stoatchat/stoatchat/issues/496)) ([a1a2125](https://github.com/stoatchat/stoatchat/commit/a1a21252d0ad58937e41f16e5fb86f96bebd2a51)) + ## [0.9.4](https://github.com/stoatchat/stoatchat/compare/v0.9.3...v0.9.4) (2026-01-10) diff --git a/Cargo.lock b/Cargo.lock index 958eb695..4489f555 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -475,9 +475,9 @@ dependencies = [ [[package]] name = "authifier" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1a03b810b342b4c584cdc1cfc40d1ec763b1d653ef4086f7494740f35d9e1f0" +checksum = "1d6163caecbb985d91406c2c6dc7710c46b38e6461d93d5e843b2f0eac43910c" dependencies = [ "async-std", "async-trait", @@ -5525,9 +5525,9 @@ dependencies = [ [[package]] name = "opentelemetry-otlp" -version = "0.31.0" +version = "0.31.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a2366db2dca4d2ad033cad11e6ee42844fd727007af5ad04a1730f4cb8163bf" +checksum = "1f69cd6acbb9af919df949cd1ec9e5e7fdc2ef15d234b6b795aaa525cc02f71f" dependencies = [ "http 1.3.1", "opentelemetry", @@ -6767,9 +6767,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" dependencies = [ "aho-corasick", "memchr", @@ -6779,9 +6779,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" dependencies = [ "aho-corasick", "memchr", @@ -6907,7 +6907,7 @@ dependencies = [ [[package]] name = "revolt-autumn" -version = "0.9.4" +version = "0.12.0" dependencies = [ "axum", "axum-macros", @@ -6937,6 +6937,7 @@ dependencies = [ "simdutf8", "strum_macros", "tempfile", + "thumbhash", "tokio 1.49.0", "tower-http 0.5.2", "tracing", @@ -6949,7 +6950,7 @@ dependencies = [ [[package]] name = "revolt-bonfire" -version = "0.9.4" +version = "0.12.0" dependencies = [ "async-channel 2.5.0", "async-std", @@ -6980,7 +6981,7 @@ dependencies = [ [[package]] name = "revolt-coalesced" -version = "0.9.4" +version = "0.12.0" dependencies = [ "indexmap 2.13.0", "lru 0.16.3", @@ -6989,7 +6990,7 @@ dependencies = [ [[package]] name = "revolt-config" -version = "0.9.4" +version = "0.12.0" dependencies = [ "async-std", "cached", @@ -7006,7 +7007,7 @@ dependencies = [ [[package]] name = "revolt-crond" -version = "0.9.4" +version = "0.12.0" dependencies = [ "log", "revolt-config", @@ -7018,7 +7019,7 @@ dependencies = [ [[package]] name = "revolt-database" -version = "0.9.4" +version = "0.12.0" dependencies = [ "amqprs", "async-lock 2.8.0", @@ -7068,7 +7069,7 @@ dependencies = [ [[package]] name = "revolt-delta" -version = "0.9.4" +version = "0.12.0" dependencies = [ "amqprs", "async-channel 1.9.0", @@ -7118,7 +7119,7 @@ dependencies = [ [[package]] name = "revolt-files" -version = "0.9.4" +version = "0.12.0" dependencies = [ "aes-gcm", "anyhow", @@ -7146,7 +7147,7 @@ dependencies = [ [[package]] name = "revolt-gifbox" -version = "0.9.4" +version = "0.12.0" dependencies = [ "axum", "axum-extra", @@ -7169,7 +7170,7 @@ dependencies = [ [[package]] name = "revolt-january" -version = "0.9.4" +version = "0.12.0" dependencies = [ "async-recursion", "axum", @@ -7197,7 +7198,7 @@ dependencies = [ [[package]] name = "revolt-models" -version = "0.9.4" +version = "0.12.0" dependencies = [ "indexmap 1.9.3", "iso8601-timestamp", @@ -7216,14 +7217,14 @@ dependencies = [ [[package]] name = "revolt-parser" -version = "0.9.4" +version = "0.12.0" dependencies = [ "logos", ] [[package]] name = "revolt-permissions" -version = "0.9.4" +version = "0.12.0" dependencies = [ "async-std", "async-trait", @@ -7238,7 +7239,7 @@ dependencies = [ [[package]] name = "revolt-presence" -version = "0.9.4" +version = "0.12.0" dependencies = [ "async-std", "log", @@ -7250,7 +7251,7 @@ dependencies = [ [[package]] name = "revolt-pushd" -version = "0.9.4" +version = "0.12.0" dependencies = [ "amqprs", "anyhow", @@ -7262,9 +7263,12 @@ dependencies = [ "iso8601-timestamp", "log", "pretty_env_logger", + "redis-kiss", + "regex", "revolt-config", "revolt-database", "revolt-models", + "revolt-parser", "revolt-presence", "revolt-result", "revolt_a2", @@ -7278,7 +7282,7 @@ dependencies = [ [[package]] name = "revolt-ratelimits" -version = "0.9.4" +version = "0.12.0" dependencies = [ "async-trait", "authifier", @@ -7295,7 +7299,7 @@ dependencies = [ [[package]] name = "revolt-result" -version = "0.9.4" +version = "0.12.0" dependencies = [ "axum", "log", @@ -7311,7 +7315,7 @@ dependencies = [ [[package]] name = "revolt-voice-ingress" -version = "0.9.4" +version = "0.12.0" dependencies = [ "amqprs", "async-std", @@ -7524,9 +7528,9 @@ dependencies = [ [[package]] name = "rocket_authifier" -version = "1.0.15" +version = "1.0.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1f98a5be96ac4144ee1adc809c1594e8212032b8a08d9bc4cef70735dfd976e" +checksum = "4a4e9660d198ea0165dc2694e7620d8fe199e923b39bec4d12b7159da5a4a6cd" dependencies = [ "authifier", "iso8601-timestamp", @@ -8945,6 +8949,12 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "thumbhash" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b7726e0245a7331bd0c9a1fb4fd99fd695bcd478ca569f0eda2ff2cb14e7a00" + [[package]] name = "tiff" version = "0.10.3" @@ -9220,9 +9230,9 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "tonic" -version = "0.14.2" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eb7613188ce9f7df5bfe185db26c5814347d110db17920415cf2fbcad85e7203" +checksum = "fec7c61a0695dc1887c1b53952990f3ad2e3a31453e1f49f10e75424943a93ec" dependencies = [ "async-trait", "base64 0.22.1", @@ -9241,9 +9251,9 @@ dependencies = [ [[package]] name = "tonic-prost" -version = "0.14.2" +version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "66bd50ad6ce1252d87ef024b3d64fe4c3cf54a86fb9ef4c631fdd0ded7aeaa67" +checksum = "a55376a0bbaa4975a3f10d009ad763d8f4108f067c7c2e74f3001fb49778d309" dependencies = [ "bytes 1.10.1", "prost 0.14.3", diff --git a/Dockerfile b/Dockerfile index 86011e56..aca233ec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build Stage -FROM --platform="${BUILDPLATFORM}" rust:1.86.0-slim-bookworm +FROM --platform="${BUILDPLATFORM}" rust:1.92.0-slim-bookworm USER 0:0 WORKDIR /home/rust/src diff --git a/Dockerfile.useCurrentArch b/Dockerfile.useCurrentArch index 82929690..9bcce8ac 100644 --- a/Dockerfile.useCurrentArch +++ b/Dockerfile.useCurrentArch @@ -1,5 +1,5 @@ # Build Stage -FROM rust:1.86.0-slim-bookworm +FROM rust:1.92.0-slim-bookworm USER 0:0 WORKDIR /home/rust/src diff --git a/README.md b/README.md index 14605969..386a0453 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,15 @@

- Revolt Backend + Stoat Backend - [![Stars](https://img.shields.io/github/stars/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/stargazers) - [![Forks](https://img.shields.io/github/forks/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/network/members) - [![Pull Requests](https://img.shields.io/github/issues-pr/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/pulls) - [![Issues](https://img.shields.io/github/issues/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/issues) - [![Contributors](https://img.shields.io/github/contributors/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/graphs/contributors) - [![License](https://img.shields.io/github/license/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/blob/main/LICENSE) + [![Stars](https://img.shields.io/github/stars/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/stargazers) + [![Forks](https://img.shields.io/github/forks/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/network/members) + [![Pull Requests](https://img.shields.io/github/issues-pr/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/pulls) + [![Issues](https://img.shields.io/github/issues/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/issues) + [![Contributors](https://img.shields.io/github/contributors/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/graphs/contributors) + [![License](https://img.shields.io/github/license/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/blob/main/LICENSE)

-The services and libraries that power the Revolt service.
+The services and libraries that power the Stoat service.

| Crate | Path | Description | | @@ -39,7 +39,7 @@ Rust 1.86.0 or higher. ## Development Guide -Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html) and the [technical documentation for this project](https://revoltchat.github.io/backend/). +Before contributing, make yourself familiar with [our contribution guidelines](https://developers.stoat.chat/developing/contrib/) and the [technical documentation for this project](https://developers.stoat.chat/). Before getting started, you'll want to install: @@ -70,8 +70,9 @@ As a heads-up, the development environment uses the following ports: Now you can clone and build the project: ```bash -git clone https://github.com/revoltchat/backend revolt-backend -cd revolt-backend +git clone https://github.com/stoatchat/stoatchat stoat-backend +cd stoat-backend +mise install mise build ``` @@ -142,9 +143,6 @@ Then continue: # start other necessary services docker compose up -d -# run everything together -./scripts/start.sh -# .. or individually # run the API server cargo run --bin revolt-delta # run the events server @@ -170,16 +168,11 @@ You can start a web client by doing the following: corepack enable # clone the web client and run it: -git clone --recursive https://github.com/revoltchat/revite -cd revite -yarn -yarn build:deps -echo "VITE_API_URL=http://local.revolt.chat:14702" > .env.local -yarn dev --port 14701 +git clone --recursive https://github.com/stoatchat/for-web stoat-web +cd stoat-web +# refer to stoat-web/README.md for startup, creating an account and loging in ``` -Then go to http://local.revolt.chat:14701 to create an account/login. - When signing up, go to http://localhost:14080 to find confirmation/password reset emails. ## Deployment Guide @@ -210,7 +203,7 @@ Tag and push a new release by running: just release ``` -If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/revoltchat/backend/releases/new) to create a changelog. +If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/stoatchat/stoatchat/releases/new) to create a changelog. ## Testing @@ -229,6 +222,6 @@ TEST_DB=MONGODB cargo nextest run ## License -The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE). +The Stoat backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/stoatchat/stoatchat/blob/main/LICENSE). **Individual crates may supply their own licenses!** diff --git a/Revolt.toml b/Revolt.toml index 0a824e2d..7398ed53 100644 --- a/Revolt.toml +++ b/Revolt.toml @@ -15,7 +15,7 @@ host = "127.0.0.1" [hosts] # Web locations of various services # Defaults assume all services are reverse-proxied -# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile +# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile # # Remember to change these to https/wss where appropriate in production! app = "http://local.revolt.chat:14701" diff --git a/compose.yml b/compose.yml index e215efcf..490fbfa4 100644 --- a/compose.yml +++ b/compose.yml @@ -73,7 +73,7 @@ services: # 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 network_mode: "host" volumes: diff --git a/crates/bonfire/Cargo.toml b/crates/bonfire/Cargo.toml index ca0ce9bb..e4481b1c 100644 --- a/crates/bonfire/Cargo.toml +++ b/crates/bonfire/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-bonfire" -version = "0.9.4" +version = "0.12.0" license = "AGPL-3.0-or-later" edition = "2021" publish = false @@ -38,7 +38,7 @@ async-std = { version = "1.8.0", features = [ ] } # core -authifier = { version = "1.0.15" } +authifier = { version = "1.0.16" } revolt-result = { path = "../core/result" } revolt-models = { path = "../core/models" } revolt-config = { path = "../core/config" } diff --git a/crates/bonfire/src/events/impl.rs b/crates/bonfire/src/events/impl.rs index e0e9ac71..12113537 100644 --- a/crates/bonfire/src/events/impl.rs +++ b/crates/bonfire/src/events/impl.rs @@ -4,7 +4,7 @@ use futures::future::join_all; use revolt_database::{ events::client::{EventV1, ReadyPayloadFields}, util::permissions::DatabasePermissionQuery, - voice::get_channel_voice_state, + voice::{get_channel_voice_state, UserVoiceChannel}, Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus, }; use revolt_models::v0; @@ -167,9 +167,13 @@ impl State { | Channel::TextChannel { voice: Some(_), .. } ) }) { - if let Ok(Some(voice_state)) = get_channel_voice_state(channel).await { + if let Ok(Some(voice_state)) = + get_channel_voice_state(&UserVoiceChannel::from_channel(channel)).await + { if let Some(server) = channel.server() { - let set = voice_state_server_members.entry(server.to_string()).or_default(); + let set = voice_state_server_members + .entry(server.to_string()) + .or_default(); for participant in &voice_state.participants { user_ids.insert(participant.id.clone()); @@ -397,6 +401,11 @@ impl State { /// Push presence change to the user and all associated server topics pub async fn broadcast_presence_change(&self, target: bool) { + let config = revolt_config::config().await; + if config.disable_events_dont_use { + return; + } + if if let Some(status) = &self.cache.users.get(&self.cache.user_id).unwrap().status { status.presence != Some(Presence::Invisible) } else { diff --git a/crates/bonfire/src/websocket.rs b/crates/bonfire/src/websocket.rs index 913245e9..ec4202dc 100644 --- a/crates/bonfire/src/websocket.rs +++ b/crates/bonfire/src/websocket.rs @@ -218,7 +218,13 @@ async fn listener( kill_signal_r: async_channel::Receiver<()>, write: &Mutex, ) { - 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!( fred::types::Builder::from_config(redis_config).build_subscriber_client() ) { @@ -422,6 +428,8 @@ async fn worker( mut read: WsReader, write: &Mutex, ) { + let revolt_config = revolt_config::config().await; + loop { let t1 = read.try_next().fuse(); let t2 = kill_signal_r.recv().fuse(); @@ -458,6 +466,10 @@ async fn worker( match payload { ClientMessage::BeginTyping { channel } => { + if revolt_config.disable_events_dont_use { + continue; + } + if !subscribed.read().await.contains(&channel) { continue; } @@ -470,6 +482,10 @@ async fn worker( .await; } ClientMessage::EndTyping { channel } => { + if revolt_config.disable_events_dont_use { + continue; + } + if !subscribed.read().await.contains(&channel) { continue; } diff --git a/crates/core/coalesced/Cargo.toml b/crates/core/coalesced/Cargo.toml index aac2b95b..4b589c0d 100644 --- a/crates/core/coalesced/Cargo.toml +++ b/crates/core/coalesced/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-coalesced" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "MIT" authors = ["Paul Makles ", "Zomatree "] diff --git a/crates/core/config/Cargo.toml b/crates/core/config/Cargo.toml index e91ce5e0..ad1ee469 100644 --- a/crates/core/config/Cargo.toml +++ b/crates/core/config/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-config" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] @@ -38,4 +38,4 @@ sentry = { version = "0.31.5", optional = true } sentry-anyhow = { version = "0.38.1", optional = true } # Core -revolt-result = { version = "0.9.4", path = "../result", optional = true } +revolt-result = { version = "0.12.0", path = "../result", optional = true } diff --git a/crates/core/config/Revolt.toml b/crates/core/config/Revolt.toml index 9813a27f..3bb33111 100644 --- a/crates/core/config/Revolt.toml +++ b/crates/core/config/Revolt.toml @@ -1,4 +1,5 @@ production = false +disable_events_dont_use = false [database] # MongoDB connection URL @@ -11,7 +12,7 @@ redis = "redis://redis/" [hosts] # Web locations of various services # Defaults assume all services are reverse-proxied -# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile +# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile # # Remember to change these to https/wss where appropriate in production! app = "http://local.revolt.chat" @@ -34,7 +35,7 @@ password = "rabbitpass" [api.registration] # Whether an invite should be required for registration -# See https://github.com/revoltchat/self-hosted#making-your-instance-invite-only +# See https://github.com/stoatchat/self-hosted#making-your-instance-invite-only invite_only = false [api.smtp] @@ -88,6 +89,11 @@ production = true # Increasing this will resolve mentions faster, but will consume more memory while resolving. mass_mention_chunk_size = 200 +# How long pushd will cache resolved names for rendered message notifications. +# Increasing this will result in lower database usage, but may result in a situation where a user/channel/role name changes +# and the notifications still resolve to the old name. +render_cache_time = 60 + # none of these should need changing exchange = "revolt.notifications" message_queue = "notifications.origin.message" @@ -220,6 +226,10 @@ new_user_hours = 72 # (should be greater than any one file upload limit) body_limit_size = 20_000_000 +# If any userids are entered here, only those users will be able to create servers. +# Leave empty to allow all users to create servers +restrict_server_creation = [] + [features.limits.new_user] # Limits imposed on new users @@ -244,7 +254,7 @@ voice_quality = 16000 # Whether the user can use video streams in voice calls video = true -# Mamimum resolution (width, height) of video streams in voice calls +# Maximum resolution (width, height) of video streams in voice calls video_resolution = [1080, 720] # Minimum and maximum aspect ratio of video streams in voice calls @@ -283,8 +293,8 @@ voice_quality = 16000 # Whether the user can use video streams in voice calls video = true -# Mamimum resolution (width, height) of video streams in voice calls -video_resolution = [1080, 720] +# Maximum resolution (width, height) of video streams in voice calls +video_resolution = [1280, 720] # Minimum and maximum aspect ratio of video streams in voice calls video_aspect_ratio = [0.3, 2.5] @@ -312,4 +322,4 @@ files = "" proxy = "" pushd = "" crond = "" -gifbox = "" \ No newline at end of file +gifbox = "" diff --git a/crates/core/config/src/lib.rs b/crates/core/config/src/lib.rs index 065658dc..65df3446 100644 --- a/crates/core/config/src/lib.rs +++ b/crates/core/config/src/lib.rs @@ -1,7 +1,7 @@ use std::{collections::HashMap, path::Path}; use cached::proc_macro::cached; -use config::{Config, File, FileFormat}; +use config::{Config, Environment, File, FileFormat}; use futures_locks::RwLock; use once_cell::sync::Lazy; use serde::Deserialize; @@ -109,6 +109,8 @@ static CONFIG_BUILDER: Lazy> = Lazy::new(|| { cwd = path.parent(); } + builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__")); + builder.build().unwrap() }) }); @@ -117,6 +119,7 @@ static CONFIG_BUILDER: Lazy> = Lazy::new(|| { pub struct Database { pub mongodb: String, pub redis: String, + pub redis_pubsub: Option, } #[derive(Deserialize, Debug, Clone)] @@ -245,6 +248,7 @@ pub struct Pushd { pub production: bool, pub exchange: String, pub mass_mention_chunk_size: usize, + pub render_cache_time: usize, // Queues pub message_queue: String, @@ -341,6 +345,8 @@ pub struct GlobalLimits { pub new_user_hours: usize, pub body_limit_size: usize, + + pub restrict_server_creation: Vec, } #[derive(Deserialize, Debug, Clone)] @@ -418,6 +424,7 @@ pub struct Settings { pub features: Features, pub sentry: Sentry, pub production: bool, + pub disable_events_dont_use: bool, } impl Settings { @@ -448,12 +455,14 @@ pub async fn config() -> Settings { let mut config = read().await.try_deserialize::().unwrap(); // inject REDIS_URI for redis-kiss library - if std::env::var("REDIS_URL").is_err() { + if std::env::var("REDIS_URI").is_err() { std::env::set_var("REDIS_URI", config.database.redis.clone()); } // auto-detect production nodes - if config.hosts.api.contains("https") && config.hosts.api.contains("revolt.chat") { + if config.hosts.api.contains("https") + && (config.hosts.api.contains("revolt.chat") || config.hosts.api.contains("stoat.chat")) + { config.production = true; } diff --git a/crates/core/database/Cargo.toml b/crates/core/database/Cargo.toml index 979d4a5d..05a66179 100644 --- a/crates/core/database/Cargo.toml +++ b/crates/core/database/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-database" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] @@ -32,19 +32,19 @@ default = ["mongodb", "async-std-runtime", "tasks"] [dependencies] # Core -revolt-config = { version = "0.9.4", path = "../config", features = [ +revolt-config = { version = "0.12.0", path = "../config", features = [ "report-macros", ] } -revolt-result = { version = "0.9.4", path = "../result" } -revolt-models = { version = "0.9.4", path = "../models", features = [ +revolt-result = { version = "0.12.0", path = "../result" } +revolt-models = { version = "0.12.0", path = "../models", features = [ "validator", ] } -revolt-presence = { version = "0.9.4", path = "../presence" } -revolt-permissions = { version = "0.9.4", path = "../permissions", features = [ +revolt-presence = { version = "0.12.0", path = "../presence" } +revolt-permissions = { version = "0.12.0", path = "../permissions", features = [ "serde", "bson", ] } -revolt-parser = { version = "0.9.4", path = "../parser" } +revolt-parser = { version = "0.12.0", path = "../parser" } # Utility log = "0.4" @@ -100,7 +100,7 @@ revolt_okapi = { version = "0.9.1", optional = true } revolt_rocket_okapi = { version = "0.10.0", optional = true } # Authifier -authifier = { version = "1.0.15" } +authifier = { version = "1.0.16" } # RabbitMQ amqprs = { version = "1.7.0" } diff --git a/crates/core/database/src/drivers/mod.rs b/crates/core/database/src/drivers/mod.rs index 9618ee13..df9a56ef 100644 --- a/crates/core/database/src/drivers/mod.rs +++ b/crates/core/database/src/drivers/mod.rs @@ -10,6 +10,7 @@ use authifier::config::SMTPSettings; use authifier::config::Shield; use authifier::config::Template; use authifier::config::Templates; +use authifier::config::EmailExpiryConfig; use authifier::Authifier; use rand::Rng; use revolt_config::config; @@ -69,7 +70,7 @@ impl DatabaseInfo { .await; #[cfg(not(feature = "mongodb"))] - return Err("MongoDB not enabled.".to_string()) + return Err("MongoDB not enabled.".to_string()); } else { DatabaseInfo::Reference.connect().await } @@ -90,7 +91,7 @@ impl DatabaseInfo { .await; #[cfg(not(feature = "mongodb"))] - return Err("MongoDB not enabled.".to_string()) + return Err("MongoDB not enabled.".to_string()); } _ => unreachable!("must specify REFERENCE or MONGODB"), } @@ -137,29 +138,33 @@ impl Database { .api .smtp .reply_to - .unwrap_or("support@revolt.chat".into()), + .unwrap_or("support@stoat.chat".into()), ), port: config.api.smtp.port, use_tls: config.api.smtp.use_tls, use_starttls: config.api.smtp.use_starttls, }, - expiry: Default::default(), + expiry: EmailExpiryConfig { + expire_verification: 3600 * 24 * 7, + expire_password_reset: 3600 * 24, + expire_account_deletion: 3600 * 24, + }, templates: if config.production { Templates { verify: Template { - title: "Verify your Revolt account.".into(), + title: "Verify your Stoat account.".into(), text: include_str!("../../templates/verify.txt").into(), url: format!("{}/login/verify/", config.hosts.app), html: Some(include_str!("../../templates/verify.html").into()), }, reset: Template { - title: "Reset your Revolt password.".into(), + title: "Reset your Stoat password.".into(), text: include_str!("../../templates/reset.txt").into(), url: format!("{}/login/reset/", config.hosts.app), html: Some(include_str!("../../templates/reset.html").into()), }, reset_existing: Template { - title: "You already have a Revolt account, reset your password." + title: "You already have a Stoat account, reset your password." .into(), text: include_str!("../../templates/reset-existing.txt").into(), url: format!("{}/login/reset/", config.hosts.app), diff --git a/crates/core/database/src/models/channels/model.rs b/crates/core/database/src/models/channels/model.rs index d8a485b9..025b7934 100644 --- a/crates/core/database/src/models/channels/model.rs +++ b/crates/core/database/src/models/channels/model.rs @@ -110,6 +110,10 @@ auto_derived!( /// Voice Information for when this channel is also a voice channel #[serde(skip_serializing_if = "Option::is_none")] voice: Option, + + /// The channel's slowmode delay in seconds + #[serde(skip_serializing_if = "Option::is_none")] + slowmode: Option, }, } @@ -146,6 +150,8 @@ auto_derived!( pub last_message_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub voice: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub slowmode: Option, } /// Optional fields on channel object @@ -206,6 +212,7 @@ impl Channel { role_permissions: HashMap::new(), nsfw: data.nsfw.unwrap_or(false), voice: data.voice.map(|voice| voice.into()), + slowmode: None }, v0::LegacyServerChannelType::Voice => Channel::TextChannel { id: id.clone(), @@ -218,6 +225,7 @@ impl Channel { role_permissions: HashMap::new(), nsfw: data.nsfw.unwrap_or(false), voice: Some(data.voice.unwrap_or_default().into()), + slowmode: None }, }; diff --git a/crates/core/database/src/models/file_hashes/model.rs b/crates/core/database/src/models/file_hashes/model.rs index cec7a39d..b25abb12 100644 --- a/crates/core/database/src/models/file_hashes/model.rs +++ b/crates/core/database/src/models/file_hashes/model.rs @@ -45,7 +45,9 @@ auto_derived!( Image { width: isize, height: isize, - // animated: bool // TODO: https://docs.rs/image/latest/image/trait.AnimationDecoder.html for APNG support + thumbhash: Option>, + #[serde(default)] + animated: bool, }, /// File is a video with specific dimensions Video { width: isize, height: isize }, diff --git a/crates/core/database/src/models/messages/model.rs b/crates/core/database/src/models/messages/model.rs index 11bc2eac..bacb3c53 100644 --- a/crates/core/database/src/models/messages/model.rs +++ b/crates/core/database/src/models/messages/model.rs @@ -1,5 +1,3 @@ -use std::{collections::HashSet, hash::RandomState}; - use indexmap::{IndexMap, IndexSet}; use iso8601_timestamp::Timestamp; use revolt_config::{config, FeaturesLimits}; @@ -9,6 +7,8 @@ use revolt_models::v0::{ }; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; use revolt_result::{ErrorType, Result}; +use std::time::SystemTime; +use std::{collections::HashSet, hash::RandomState}; use ulid::Ulid; use validator::Validate; @@ -388,6 +388,7 @@ impl Message { mut role_mentions, mut mentions_everyone, mut mentions_online, + .. } = message_mentions; if allow_mass_mentions && server_id.is_some() && !role_mentions.is_empty() { @@ -497,7 +498,7 @@ impl Message { user_mentions.retain(|m| recipients_hash.contains(m)); role_mentions.clear(); } - Channel::TextChannel { ref server, .. }=> { + Channel::TextChannel { ref server, .. } => { let mentions_vec = Vec::from_iter(user_mentions.iter().cloned()); let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await; @@ -687,8 +688,13 @@ impl Message { ) .await?; + let is_dm_or_group = matches!( + channel, + Channel::DirectMessage { .. } | Channel::Group { .. } + ); + if !self.has_suppressed_notifications() - && (self.mentions.is_some() || self.contains_mass_push_mention()) + && (is_dm_or_group || self.mentions.is_some() || self.contains_mass_push_mention()) { // send Push notifications #[cfg(feature = "tasks")] @@ -699,7 +705,7 @@ impl Message { Some( PushNotification::from( self.clone().into_model(user, member), - Some(author), + Some(author.clone()), channel.to_owned().into(), ) .await, @@ -707,7 +713,11 @@ impl Message { self.clone(), match channel { Channel::DirectMessage { recipients, .. } - | Channel::Group { recipients, .. } => recipients.clone(), + | Channel::Group { recipients, .. } => recipients + .iter() + .filter(|uid| *uid != author.id()) + .cloned() + .collect(), Channel::TextChannel { .. } => { self.mentions.clone().unwrap_or_default() } @@ -1029,6 +1039,31 @@ impl Message { Ok(()) } + /// Bulk delete messages by an author since a given time + pub async fn bulk_delete_by_author_since( + db: &Database, + channels: &[String], + author: &str, + since: SystemTime, + ) -> Result<()> { + let deleted_groups = db + .delete_messages_by_author_since(channels, author, since) + .await?; + + for (channel_id, message_ids) in deleted_groups { + if !message_ids.is_empty() { + EventV1::BulkMessageDelete { + channel: channel_id.clone(), + ids: message_ids, + } + .p(channel_id) + .await; + } + } + + Ok(()) + } + /// Remove a reaction from a message pub async fn remove_reaction(&self, db: &Database, user: &str, emoji: &str) -> Result<()> { // Check if it actually exists diff --git a/crates/core/database/src/models/messages/ops.rs b/crates/core/database/src/models/messages/ops.rs index 4d54e83c..6ebdf4c3 100644 --- a/crates/core/database/src/models/messages/ops.rs +++ b/crates/core/database/src/models/messages/ops.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; +use std::time::SystemTime; use revolt_result::Result; use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage}; @@ -40,4 +42,12 @@ pub trait AbstractMessages: Sync + Send { /// Delete messages from a channel by their ids and corresponding channel id async fn delete_messages(&self, channel: &str, ids: &[String]) -> Result<()>; + + /// Delete all messages from a specific author in a server from a certain ULID onwards + async fn delete_messages_by_author_since( + &self, + channels: &[String], + author: &str, + since: SystemTime + ) -> Result>>; } diff --git a/crates/core/database/src/models/messages/ops/mongodb.rs b/crates/core/database/src/models/messages/ops/mongodb.rs index 449e71ef..2a79ed5b 100644 --- a/crates/core/database/src/models/messages/ops/mongodb.rs +++ b/crates/core/database/src/models/messages/ops/mongodb.rs @@ -1,8 +1,12 @@ use bson::{to_bson, Document}; use futures::try_join; +use futures::StreamExt; use mongodb::options::FindOptions; use revolt_models::v0::MessageSort; use revolt_result::Result; +use std::collections::{HashMap, HashSet}; +use std::time::SystemTime; +use ulid::Ulid; use crate::{ AppendMessage, DocumentId, FieldsMessage, IntoDocumentPath, Message, MessageQuery, @@ -306,6 +310,112 @@ impl AbstractMessages for MongoDb { .map(|_| ()) .map_err(|_| create_database_error!("delete_many", COL)) } + + /// Delete all messages from a specific author in a server from a certain ULID onwards + async fn delete_messages_by_author_since( + &self, + channels: &[String], + author: &str, + since: SystemTime, + ) -> Result>> { + 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::::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::::new(), + "in": { "$setUnion": ["$$value", "$$this"] } + } + } + } + }, + ]; + + #[derive(serde::Deserialize)] + struct AggregatedChannel { + #[serde(rename = "_id")] + channel: String, + message_ids: Vec, + #[serde(default)] + attachment_ids: Vec, + } + + let mut cursor = self + .col::(COL) + .aggregate(pipeline) + .await + .map_err(|_| create_database_error!("aggregate", COL))? + .with_type::(); + + let mut deleted_messages: HashMap> = HashMap::new(); + let mut attachment_ids: HashSet = 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::("attachments") + .update_many( + doc! { + "_id": { + "$in": attachment_ids.into_iter().collect::>() + } + }, + doc! { + "$set": { + "deleted": true + } + }, + ) + .await + .map_err(|_| create_database_error!("update_many", "attachments"))?; + } + + self.col::(COL) + .delete_many(filter) + .await + .map_err(|_| create_database_error!("delete_many", COL))?; + + Ok(deleted_messages) + } } impl IntoDocumentPath for FieldsMessage { diff --git a/crates/core/database/src/models/messages/ops/reference.rs b/crates/core/database/src/models/messages/ops/reference.rs index ae5b43f0..6f30de6f 100644 --- a/crates/core/database/src/models/messages/ops/reference.rs +++ b/crates/core/database/src/models/messages/ops/reference.rs @@ -1,7 +1,9 @@ +use std::collections::HashMap; use futures::future::try_join_all; use indexmap::IndexSet; use revolt_result::Result; - +use std::time::SystemTime; +use ulid::Ulid; use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb}; use super::AbstractMessages; @@ -286,4 +288,63 @@ impl AbstractMessages for ReferenceDb { Ok(()) } + + /// Delete all messages from a specific author in a list of channels from a certain ULID onwards + async fn delete_messages_by_author_since( + &self, + channels: &[String], + author: &str, + since: SystemTime + ) -> Result>> { + let threshold_ulid = Ulid::from_datetime(since).to_string(); + let mut deleted_messages: HashMap> = HashMap::new(); + let mut attachment_ids: Vec = 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) + } } diff --git a/crates/core/database/src/models/server_members/model.rs b/crates/core/database/src/models/server_members/model.rs index 0062a270..e242c62f 100644 --- a/crates/core/database/src/models/server_members/model.rs +++ b/crates/core/database/src/models/server_members/model.rs @@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use crate::{ - events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, - Database, File, Server, SystemMessage, User, + events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File, + Server, SystemMessage, User, }; fn default_true() -> bool { @@ -45,7 +45,6 @@ auto_derived_partial!( /// Whether the member is server-wide voice deafened #[serde(skip_serializing_if = "is_true", default = "default_true")] pub can_receive: bool, - // This value only exists in the database, not the models. // If it is not-None, the database layer should return None to member fetching queries. // pub pending_deletion_at: Option @@ -153,7 +152,11 @@ impl Member { #[cfg(feature = "voice")] for channel in &channels { - if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state(channel).await { + if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state( + &crate::voice::UserVoiceChannel::from_channel(channel), + ) + .await + { voice_states.push(voice_state) } } @@ -175,7 +178,7 @@ impl Member { .map(|channel| channel.into()) .collect(), emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(), - voice_states + voice_states, } .private(user.id.clone()) .await; @@ -225,14 +228,14 @@ impl Member { pub fn remove_field(&mut self, field: &FieldsMember) { match field { - FieldsMember::JoinedAt => {}, + FieldsMember::JoinedAt => {} FieldsMember::Avatar => self.avatar = None, FieldsMember::Nickname => self.nickname = None, FieldsMember::Roles => self.roles.clear(), FieldsMember::Timeout => self.timeout = None, FieldsMember::CanReceive => self.can_receive = true, FieldsMember::CanPublish => self.can_publish = true, - FieldsMember::VoiceChannel => {}, + FieldsMember::VoiceChannel => {} } } diff --git a/crates/core/database/src/util/bridge/v0.rs b/crates/core/database/src/util/bridge/v0.rs index aafb2313..2e637795 100644 --- a/crates/core/database/src/util/bridge/v0.rs +++ b/crates/core/database/src/util/bridge/v0.rs @@ -190,6 +190,7 @@ impl From for Channel { role_permissions, nsfw, voice, + slowmode } => Channel::TextChannel { id, server, @@ -201,6 +202,7 @@ impl From for Channel { role_permissions, nsfw, voice: voice.map(|voice| voice.into()), + slowmode }, } } @@ -254,6 +256,7 @@ impl From for crate::Channel { role_permissions, nsfw, voice, + slowmode } => crate::Channel::TextChannel { id, server, @@ -265,6 +268,7 @@ impl From for crate::Channel { role_permissions, nsfw, voice: voice.map(|voice| voice.into()), + slowmode }, } } @@ -283,7 +287,8 @@ impl From for PartialChannel { role_permissions: value.role_permissions, default_permissions: value.default_permissions, last_message_id: value.last_message_id, - voice: value.voice.map(|voice| voice.into()) + voice: value.voice.map(|voice| voice.into()), + slowmode: value.slowmode, } } } @@ -301,7 +306,8 @@ impl From for crate::PartialChannel { role_permissions: value.role_permissions, default_permissions: value.default_permissions, last_message_id: value.last_message_id, - voice: value.voice.map(|voice| voice.into()) + voice: value.voice.map(|voice| voice.into()), + slowmode: value.slowmode } } } @@ -406,9 +412,16 @@ impl From for Metadata { match value { crate::Metadata::File => Metadata::File, crate::Metadata::Text => Metadata::Text, - crate::Metadata::Image { width, height } => Metadata::Image { + crate::Metadata::Image { + width, + height, + thumbhash, + animated, + } => Metadata::Image { width: width as usize, height: height as usize, + thumbhash, + animated, }, crate::Metadata::Video { width, height } => Metadata::Video { width: width as usize, @@ -424,9 +437,16 @@ impl From for crate::Metadata { match value { Metadata::File => crate::Metadata::File, Metadata::Text => crate::Metadata::Text, - Metadata::Image { width, height } => crate::Metadata::Image { + Metadata::Image { + width, + height, + thumbhash, + animated, + } => crate::Metadata::Image { width: width as isize, height: height as isize, + thumbhash, + animated, }, Metadata::Video { width, height } => crate::Metadata::Video { width: width as isize, @@ -515,7 +535,9 @@ impl From for SystemMessage { crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by }, crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by }, crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by }, - crate::SystemMessage::CallStarted { by, finished_at } => Self::CallStarted { by, finished_at } + crate::SystemMessage::CallStarted { by, finished_at } => { + Self::CallStarted { by, finished_at } + } } } } @@ -1382,7 +1404,7 @@ impl From for crate::FieldsMessage { impl From for crate::VoiceInformation { fn from(value: VoiceInformation) -> Self { crate::VoiceInformation { - max_users: value.max_users + max_users: value.max_users, } } } @@ -1390,7 +1412,7 @@ impl From for crate::VoiceInformation { impl From for VoiceInformation { fn from(value: crate::VoiceInformation) -> Self { VoiceInformation { - max_users: value.max_users + max_users: value.max_users, } } -} \ No newline at end of file +} diff --git a/crates/core/database/src/voice/mod.rs b/crates/core/database/src/voice/mod.rs index 56009a53..836de92a 100644 --- a/crates/core/database/src/voice/mod.rs +++ b/crates/core/database/src/voice/mod.rs @@ -1,3 +1,5 @@ +use std::fmt::{Display, Write}; + use crate::{ events::client::EventV1, models::{Channel, User}, @@ -6,7 +8,11 @@ use crate::{ }; use iso8601_timestamp::{Duration, Timestamp}; use livekit_protocol::ParticipantPermission; -use redis_kiss::{get_connection as _get_connection, redis::Pipeline, AsyncCommands, Conn}; +use redis_kiss::{ + get_connection as _get_connection, + redis::{FromRedisValue, Pipeline, RedisError, RedisWrite, ToRedisArgs, Value}, + AsyncCommands, Conn, +}; use revolt_config::FeaturesLimits; use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue}; @@ -16,48 +22,60 @@ mod voice_client; pub use voice_client::VoiceClient; async fn get_connection() -> Result { - _get_connection().await.map_err(|_| create_error!(InternalError)) + _get_connection() + .await + .map_err(|_| create_error!(InternalError)) } -pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> { +pub async fn raise_if_in_voice(user: &User, channel: &UserVoiceChannel) -> Result<()> { let mut conn = get_connection().await?; - if user.bot.is_some() - // bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to - && conn.sismember(format!("vc:{}", &user.id), channel_id) + if user.bot.is_some() { + // bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to + if conn + .sismember(format!("vc:{}", &user.id), channel) .await .to_internal_error()? - { - Err(create_error!(AlreadyConnected)) + { + return Err(create_error!(AlreadyConnected)); + }; } else if conn .scard::<_, u32>(format!("vc:{}", &user.id)) // check if the current vc set is empty .await .to_internal_error()? > 0 { - Err(create_error!(AlreadyConnected)) - } else { - Ok(()) - } + return Err(create_error!(AlreadyConnected)); + }; + + Ok(()) } -pub async fn set_channel_node(channel: &str, node: &str) -> Result<()> { +pub async fn set_channel_node(channel_id: &str, node: &str) -> Result<()> { get_connection() .await? - .set(format!("node:{channel}"), node) + .set(format!("node:{channel_id}"), node) .await .to_internal_error() } -pub async fn get_channel_node(channel: &str) -> Result> { +pub async fn get_channel_node(channel_id: &str) -> Result> { get_connection() .await? - .get(format!("node:{channel}")) + .get(format!("node:{channel_id}")) .await .to_internal_error() } -pub async fn get_user_voice_channels(user_id: &str) -> Result> { +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> { get_connection() .await? .smembers(format!("vc:{user_id}")) @@ -66,14 +84,14 @@ pub async fn get_user_voice_channels(user_id: &str) -> Result> { } pub async fn set_user_moved_from_voice( - old_channel: &str, - new_channel: &str, + old_channel_id: &str, + new_channel: &UserVoiceChannel, user_id: &str, ) -> Result<()> { get_connection() .await? .set_ex( - format!("moved_from:{user_id}:{old_channel}"), + format!("moved_from:{user_id}:{old_channel_id}"), new_channel, 10, ) @@ -90,18 +108,25 @@ pub async fn get_user_moved_from_voice(channel_id: &str, user_id: &str) -> Resul } pub async fn set_user_moved_to_voice( - new_channel: &str, - old_channel: &str, + new_channel_id: &str, + old_channel: &UserVoiceChannel, user_id: &str, ) -> Result<()> { get_connection() .await? - .set_ex(format!("moved_to:{user_id}:{new_channel}"), old_channel, 10) + .set_ex( + format!("moved_to:{user_id}:{new_channel_id}"), + old_channel, + 10, + ) .await .to_internal_error() } -pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result> { +pub async fn get_user_moved_to_voice( + channel_id: &str, + user_id: &str, +) -> Result> { get_connection() .await? .get_del(format!("moved_to:{user_id}:{channel_id}")) @@ -109,10 +134,10 @@ pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result< .to_internal_error() } -pub async fn is_in_voice_channel(user_id: &str, channel_id: &str) -> Result { +pub async fn is_in_voice_channel(user_id: &str, channel: &UserVoiceChannel) -> Result { get_connection() .await? - .sismember(format!("vc:{user_id}"), channel_id) + .sismember(format!("vc:{user_id}"), channel) .await .to_internal_error() } @@ -146,12 +171,15 @@ pub fn get_allowed_sources( } pub async fn create_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, joined_at: Timestamp, ) -> Result { - let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); let voice_state = UserVoiceState { joined_at, @@ -163,9 +191,9 @@ pub async fn create_voice_state( }; Pipeline::new() - .sadd(format!("vc_members:{channel_id}"), user_id) - .sadd(format!("vc:{user_id}"), channel_id) - .set(&unique_key, channel_id) + .sadd(format!("vc_members:{}", &channel.id), user_id) + .sadd(format!("vc:{user_id}"), channel) + .set(&unique_key, &channel.id) .set( format!("joined_at:{unique_key}"), joined_at @@ -192,16 +220,16 @@ pub async fn create_voice_state( Ok(voice_state) } -pub async fn delete_voice_state( - channel_id: &str, - server_id: Option<&str>, - user_id: &str, -) -> Result<()> { - let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); +pub async fn delete_voice_state(channel: &UserVoiceChannel, user_id: &str) -> Result<()> { + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); Pipeline::new() - .srem(format!("vc_members:{channel_id}"), user_id) - .srem(format!("vc:{user_id}"), channel_id) + .srem(format!("vc_members:{}", &channel.id), user_id) + .srem(format!("vc:{user_id}"), channel) .del(&[ format!("joined_at:{unique_key}"), format!("is_publishing:{unique_key}"), @@ -216,19 +244,19 @@ pub async fn delete_voice_state( } pub async fn delete_channel_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_ids: &[String], ) -> Result<()> { - let parent_id = server_id.unwrap_or(channel_id); + let parent_id = channel.server_id.as_ref().unwrap_or(&channel.id); let mut pipeline = Pipeline::new(); - pipeline.del(format!("vc_members:{channel_id}")); + pipeline.del(format!("vc_members:{}", &channel.id)); + pipeline.del(format!("node:{}", &channel.id)); for user_id in user_ids { let unique_key = format!("{user_id}:{parent_id}"); - pipeline.srem(format!("vc:{user_id}"), channel_id).del(&[ + pipeline.srem(format!("vc:{user_id}"), channel).del(&[ format!("joined_at:{unique_key}"), format!("is_publishing:{unique_key}"), format!("is_receiving:{unique_key}"), @@ -245,8 +273,7 @@ pub async fn delete_channel_voice_state( } pub async fn update_voice_state_tracks( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, added: bool, track: i32, @@ -271,18 +298,21 @@ pub async fn update_voice_state_tracks( _ => unreachable!(), }; - update_voice_state(channel_id, server_id, user_id, &partial).await?; + update_voice_state(channel, user_id, &partial).await?; Ok(partial) } pub async fn update_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, partial: &PartialUserVoiceState, ) -> Result<()> { - let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id)); + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); let mut pipeline = Pipeline::new(); @@ -308,21 +338,24 @@ pub async fn update_voice_state( .to_internal_error() } -pub async fn get_voice_channel_members(channel_id: &str) -> Result>> { +pub async fn get_voice_channel_members(channel: &UserVoiceChannel) -> Result>> { get_connection() .await? - .smembers::<_, Option>>(format!("vc_members:{channel_id}")) + .smembers::<_, Option>>(format!("vc_members:{}", &channel.id)) .await .to_internal_error() .map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) })) } pub async fn get_voice_state( - channel_id: &str, - server_id: Option<&str>, + channel: &UserVoiceChannel, user_id: &str, ) -> Result> { - let unique_key = format!("{}:{}", user_id, server_id.unwrap_or(channel_id)); + let unique_key = format!( + "{}:{}", + &user_id, + channel.server_id.as_ref().unwrap_or(&channel.id) + ); let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection() .await? @@ -363,21 +396,21 @@ pub async fn get_voice_state( } } -pub async fn get_channel_voice_state(channel: &Channel) -> Result> { - let members = get_voice_channel_members(channel.id()).await?; - - let server = channel.server(); +pub async fn get_channel_voice_state( + channel: &UserVoiceChannel, +) -> Result> { + let members = get_voice_channel_members(channel).await?; if let Some(members) = members { let mut participants = Vec::with_capacity(members.len()); for user_id in members { - if let Some(voice_state) = get_voice_state(channel.id(), server, &user_id).await? { + if let Some(voice_state) = get_voice_state(channel, &user_id).await? { participants.push(voice_state); } else { log::info!("Voice state not found but member in voice channel members, removing."); - delete_voice_state(channel.id(), server, &user_id).await?; + delete_voice_state(channel, &user_id).await?; } } @@ -385,7 +418,7 @@ pub async fn get_channel_voice_state(channel: &Channel) -> Result Result Result<()> { +pub async fn move_user(user: &str, from_channel_id: &str, to_channel_id: &str) -> Result<()> { get_connection() .await? .smove( - format!("vc-members-{from}"), - format!("vc-members-{to}"), + format!("vc_members:{from_channel_id}"), + format!("vc_members:{to_channel_id}"), user, ) .await @@ -412,11 +445,13 @@ pub async fn sync_voice_permissions( server: Option<&Server>, role_id: Option<&str>, ) -> Result<()> { + let user_voice_channel = UserVoiceChannel::from_channel(channel); + let Some(node) = get_channel_node(channel.id()).await? else { return Ok(()); }; - for user_id in get_voice_channel_members(channel.id()) + for user_id in get_voice_channel_members(&user_voice_channel) .await? .iter() .flatten() @@ -456,7 +491,9 @@ pub async fn sync_user_voice_permissions( .as_ref() .is_none_or(|member| member.roles.iter().any(|r| r == role_id)) }) { - let Some(voice_state) = get_voice_state(channel_id, server_id, &user.id).await? else { + let user_voice_channel = UserVoiceChannel::from_channel(channel); + + let Some(voice_state) = get_voice_state(&user_voice_channel, &user.id).await? else { return Ok(()); }; @@ -487,7 +524,7 @@ pub async fn sync_user_voice_permissions( update_event.screensharing = voice_state.screensharing.then_some(can_video); update_event.is_publishing = voice_state.is_publishing.then_some(can_speak); - update_voice_state(channel_id, server_id, &user.id, &update_event).await?; + update_voice_state(&user_voice_channel, &user.id, &update_event).await?; voice_client .update_permissions( @@ -565,34 +602,95 @@ pub async fn get_call_notification_recipients( .to_internal_error() } -pub async fn remove_user_from_voice_channels(db: &Database, voice_client: &VoiceClient, user_id: &str) -> Result<()> { - for channel_id in get_user_voice_channels(user_id).await? { - remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?; - }; - - Ok(()) -} - -pub async fn remove_user_from_voice_channel(db: &Database, voice_client: &VoiceClient, channel_id: &str, user_id: &str) -> Result<()> { - if let Some(node) = get_channel_node(channel_id).await? { - let _ = voice_client.remove_user(&node, user_id, channel_id).await; +pub async fn remove_user_from_voice_channels( + voice_client: &VoiceClient, + user_id: &str, +) -> Result<()> { + for channel in get_user_voice_channels(user_id).await? { + remove_user_from_voice_channel(voice_client, &channel, user_id).await?; } - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + Ok(()) +} - delete_voice_state(channel_id, channel.server(), user_id).await?; +pub async fn remove_user_from_voice_channel( + voice_client: &VoiceClient, + channel: &UserVoiceChannel, + user_id: &str, +) -> Result<()> { + if let Some(node) = get_channel_node(&channel.id).await? { + let _ = voice_client.remove_user(&node, user_id, &channel.id).await; + } + + delete_voice_state(channel, user_id).await?; Ok(()) } -pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, server_id: Option<&str>) -> Result<()> { - if let Some(users) = get_voice_channel_members(channel_id).await? { - let node = get_channel_node(channel_id).await?.unwrap(); +pub async fn delete_voice_channel( + voice_client: &VoiceClient, + channel: &UserVoiceChannel, +) -> Result<()> { + if let Some(users) = get_voice_channel_members(channel).await? { + let node = get_channel_node(&channel.id).await?.unwrap(); + voice_client.delete_room(&node, &channel.id).await?; - voice_client.delete_room(&node, channel_id).await?; - - delete_channel_voice_state(channel_id, server_id, &users).await?; + delete_channel_voice_state(channel, &users).await?; }; Ok(()) -} \ No newline at end of file +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct RoomMetadata { + pub server: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UserVoiceChannel { + pub id: String, + pub server_id: Option, +} + +impl UserVoiceChannel { + pub fn from_string(input: String) -> Self { + let mut parts = input.splitn(2, '-'); + + Self { + id: parts.next().unwrap().to_string(), + server_id: parts.next().map(ToString::to_string), + } + } + + pub fn from_channel(channel: &Channel) -> Self { + Self { + id: channel.id().to_string(), + server_id: channel.server().map(ToString::to_string), + } + } +} + +impl Display for UserVoiceChannel { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.id)?; + + if let Some(server_id) = &self.server_id { + f.write_char('-')?; + f.write_str(server_id)? + }; + + Ok(()) + } +} + +impl ToRedisArgs for UserVoiceChannel { + fn write_redis_args(&self, out: &mut W) { + out.write_arg_fmt(self); + } +} + +impl FromRedisValue for UserVoiceChannel { + fn from_redis_value(v: &Value) -> Result { + String::from_redis_value(v).map(UserVoiceChannel::from_string) + } +} diff --git a/crates/core/database/src/voice/voice_client.rs b/crates/core/database/src/voice/voice_client.rs index 967f3fef..94d1f22b 100644 --- a/crates/core/database/src/voice/voice_client.rs +++ b/crates/core/database/src/voice/voice_client.rs @@ -1,5 +1,6 @@ use crate::{ models::{Channel, User}, + voice::RoomMetadata, Database, }; use livekit_api::{ @@ -102,11 +103,16 @@ impl VoiceClient { pub async fn create_room(&self, node: &str, channel: &Channel) -> Result { let room = self.get_node(node)?; + let metadata = RoomMetadata { + server: channel.server().map(|id| id.to_string()), + }; + room.client .create_room( channel.id(), CreateRoomOptions { empty_timeout: 5 * 60, // 5 minutes, + metadata: serde_json::to_string(&metadata).to_internal_error()?, ..Default::default() }, ) diff --git a/crates/core/database/templates/deletion.whitelabel.txt b/crates/core/database/templates/deletion.whitelabel.txt index 3447b351..b040a51e 100644 --- a/crates/core/database/templates/deletion.whitelabel.txt +++ b/crates/core/database/templates/deletion.whitelabel.txt @@ -6,4 +6,4 @@ This email is intended for {{email}} This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.stoat.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/crates/core/database/templates/reset.whitelabel.txt b/crates/core/database/templates/reset.whitelabel.txt index 9ac5028f..10e8ca2d 100644 --- a/crates/core/database/templates/reset.whitelabel.txt +++ b/crates/core/database/templates/reset.whitelabel.txt @@ -4,6 +4,6 @@ Please navigate to: {{url}} This email is intended for {{email}} -This email has no association with Revolt or Revolt Platforms Ltd. +This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.revolt.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/crates/core/database/templates/suspension.html b/crates/core/database/templates/suspension.html index 2e4c2298..27b7331e 100644 --- a/crates/core/database/templates/suspension.html +++ b/crates/core/database/templates/suspension.html @@ -107,7 +107,7 @@

Further violations may result in a permanent ban depending on severity, please abide by the - Acceptable Usage Policy. + Community Guidelines.

diff --git a/crates/core/database/templates/suspension.txt b/crates/core/database/templates/suspension.txt index d247eae3..293f8a0c 100644 --- a/crates/core/database/templates/suspension.txt +++ b/crates/core/database/templates/suspension.txt @@ -3,7 +3,7 @@ Your account has been suspended, for one or more reasons: You will be able to use your account again in {{duration}} days. -Further violations may result in a permanent ban depending on severity, please abide by the Acceptable Usage Policy (https://revolt.chat/aup). +Further violations may result in a permanent ban depending on severity, please abide by the Community Guidelines (https://stoat.chat/legal/community-guidelines). Ban evasion is prohibited and will be dealt with accordingly. diff --git a/crates/core/database/templates/suspension.whitelabel.txt b/crates/core/database/templates/suspension.whitelabel.txt index 45d9aa56..bd82145d 100644 --- a/crates/core/database/templates/suspension.whitelabel.txt +++ b/crates/core/database/templates/suspension.whitelabel.txt @@ -3,6 +3,6 @@ Your account has been suspended, for one or more reasons: This email is intended for {{email}} -This email has no association with Revolt or Revolt Platforms Ltd. +This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.revolt.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/crates/core/database/templates/verify.whitelabel.txt b/crates/core/database/templates/verify.whitelabel.txt index fb54088d..16f1c4b1 100644 --- a/crates/core/database/templates/verify.whitelabel.txt +++ b/crates/core/database/templates/verify.whitelabel.txt @@ -5,6 +5,6 @@ Please navigate to: {{url}} This email is intended for {{email}} -This email has no association with Revolt or Revolt Platforms Ltd. +This email has no association with Stoat or Revolt Platforms Ltd. Learn more about third party instances here: -https://developers.revolt.chat/faq.html +https://developers.stoat.chat/faq/ diff --git a/crates/core/files/Cargo.toml b/crates/core/files/Cargo.toml index f34469e8..2a4a5205 100644 --- a/crates/core/files/Cargo.toml +++ b/crates/core/files/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-files" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] @@ -27,10 +27,10 @@ typenum = "1.17.0" aws-config = "1.5.5" aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] } -revolt-config = { version = "0.9.4", path = "../config", features = [ +revolt-config = { version = "0.12.0", path = "../config", features = [ "report-macros", ] } -revolt-result = { version = "0.9.4", path = "../result" } +revolt-result = { version = "0.12.0", path = "../result" } # image processing jxl-oxide = { workspace = true } diff --git a/crates/core/files/src/implementation/media_impl.rs b/crates/core/files/src/implementation/media_impl.rs index bd39534a..7a7fad84 100644 --- a/crates/core/files/src/implementation/media_impl.rs +++ b/crates/core/files/src/implementation/media_impl.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use image::{DynamicImage, ImageBuffer, ImageReader}; +use image::{AnimationDecoder, DynamicImage, ImageBuffer, ImageReader}; use jxl_oxide::integration::JxlDecoder; use revolt_config::report_internal_error; use std::io::{BufRead, Read, Seek}; @@ -35,6 +35,31 @@ impl MediaRepository for MediaImpl { } } + fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option { + match mime { + // Current behaviour is to assume GIFs are animated, this checks for at least 2 frames + "image/gif" => { + let file = std::fs::File::open(f.path()).ok()?; + let reader = std::io::BufReader::new(file); + let decoder = image::codecs::gif::GifDecoder::new(reader).ok()?; + Some(decoder.into_frames().take(2).count() > 1) + } + "image/png" => { + let file = std::fs::File::open(f.path()).ok()?; + let reader = std::io::BufReader::new(file); + let decoder = image::codecs::png::PngDecoder::new(reader).ok()?; + decoder.is_apng().ok() + } + "image/webp" => { + let file = std::fs::File::open(f.path()).ok()?; + let reader = std::io::BufReader::new(file); + let decoder = image::codecs::webp::WebPDecoder::new(reader).ok()?; + Some(decoder.has_animation()) + } + _ => Some(false), + } + } + fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)> { match mime { "image/svg+xml" => { @@ -132,6 +157,17 @@ impl MediaRepository for MediaImpl { let [w, h] = self.config.preview.get(tag).unwrap(); let image = image.thumbnail(image.width().min(*w as u32), image.height().min(*h as u32)); + let image = match image { + DynamicImage::ImageRgb8(_) => image, + DynamicImage::ImageRgba8(_) => image, + _ => { + if image.has_alpha() { + image.to_rgba8().into() + } else { + image.to_rgb8().into() + } + } + }; let encoder = webp::Encoder::from_image(&image).expect("Could not create encoder."); if self.config.webp_quality != 100.0 { @@ -160,9 +196,9 @@ impl MediaRepository for MediaImpl { #[cfg(test)] mod tests { - use std::io::Cursor; - use crate::{MediaImpl, MediaRepository}; + use std::io::{Cursor, Write}; + use tempfile::NamedTempFile; #[tokio::test] async fn asset_test_jpeg() { @@ -175,6 +211,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_jpeg_is_not_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/test.jpeg")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/jpeg"), Some(false)); + } + #[tokio::test] async fn asset_test_jpeg_extra_bytes() { let media = MediaImpl::from_config().await; @@ -201,6 +246,15 @@ mod tests { media.create_thumbnail(image, "emojis"); } + #[tokio::test] + async fn asset_test_png_is_not_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/test.png")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/png"), Some(false)); + } + #[tokio::test] async fn asset_test_png_extra_bytes() { let media = MediaImpl::from_config().await; @@ -216,6 +270,17 @@ mod tests { media.create_thumbnail(image, "emojis"); } + #[tokio::test] + async fn asset_test_floating_point_png() { + let media = MediaImpl::from_config().await; + let buf = include_bytes!("../../tests/assets/test-float.png"); + assert_eq!(media.image_size_vec(buf, "image/png"), Some((300, 300))); + + let mut reader = Cursor::new(buf); + let image = media.decode_image(&mut reader, "image/png").unwrap(); + media.create_thumbnail(image, "avatars"); + } + #[tokio::test] async fn asset_test_corrupted_png() { let media = MediaImpl::from_config().await; @@ -237,6 +302,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_animated_png_is_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/anim-icos.apng")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/png"), Some(true)); + } + #[tokio::test] async fn asset_test_jxl() { let media = MediaImpl::from_config().await; @@ -270,6 +344,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_webp_is_not_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/dice.webp")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/webp"), Some(false)); + } + #[tokio::test] async fn asset_test_animated_webp() { let media = MediaImpl::from_config().await; @@ -281,6 +364,15 @@ mod tests { media.create_thumbnail(image, "attachments"); } + #[tokio::test] + async fn asset_test_animated_webp_is_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/anim-icos.webp")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/webp"), Some(true)); + } + #[tokio::test] async fn asset_test_animated_gif() { let media = MediaImpl::from_config().await; @@ -291,4 +383,13 @@ mod tests { let image = media.decode_image(&mut reader, "image/gif").unwrap(); media.create_thumbnail(image, "attachments"); } + + #[tokio::test] + async fn asset_test_animated_gif_is_animated() { + let media = MediaImpl::from_config().await; + let mut f = NamedTempFile::new().unwrap(); + f.write_all(include_bytes!("../../tests/assets/anim-icos.gif")) + .unwrap(); + assert_eq!(media.is_animated(&f, "image/gif"), Some(true)); + } } diff --git a/crates/core/files/src/lib.rs b/crates/core/files/src/lib.rs index b1c0982d..0f71f095 100644 --- a/crates/core/files/src/lib.rs +++ b/crates/core/files/src/lib.rs @@ -91,6 +91,34 @@ pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> { media.image_size_vec(v, mime) } +/// Check whether an image file contains animation data +pub fn is_animated(f: &NamedTempFile, mime: &str) -> Option { + let media = MediaImpl::new(Files { + blocked_mime_types: Default::default(), + clamd_host: Default::default(), + encryption_key: Default::default(), + limit: FilesLimit { + max_mega_pixels: 0, + max_pixel_side: 0, + min_file_size: 0, + min_resolution: [0, 0], + }, + preview: Default::default(), + s3: FilesS3 { + access_key_id: Default::default(), + default_bucket: Default::default(), + endpoint: Default::default(), + path_style_buckets: Default::default(), + region: Default::default(), + secret_access_key: Default::default(), + }, + scan_mime_types: Default::default(), + webp_quality: Default::default(), + }); + + media.is_animated(f, mime) +} + /// Determine size of video at temp file pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> { let media = MediaImpl::new(Files { diff --git a/crates/core/files/src/repositories/media_repository.rs b/crates/core/files/src/repositories/media_repository.rs index 4cc4feca..6c489d9b 100644 --- a/crates/core/files/src/repositories/media_repository.rs +++ b/crates/core/files/src/repositories/media_repository.rs @@ -6,6 +6,9 @@ use thiserror::Error; pub trait MediaRepository: Send + Sync + 'static { fn image_size(&self, f: &NamedTempFile) -> Option<(usize, usize)>; + + fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option; + fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)>; fn decode_image( diff --git a/crates/core/files/tests/assets/test-float.png b/crates/core/files/tests/assets/test-float.png new file mode 100644 index 00000000..036e9d4d Binary files /dev/null and b/crates/core/files/tests/assets/test-float.png differ diff --git a/crates/core/models/Cargo.toml b/crates/core/models/Cargo.toml index d5111c11..a1a65e19 100644 --- a/crates/core/models/Cargo.toml +++ b/crates/core/models/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-models" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] @@ -21,8 +21,8 @@ default = ["serde", "partials", "rocket"] [dependencies] # Core -revolt-config = { version = "0.9.4", path = "../config" } -revolt-permissions = { version = "0.9.4", path = "../permissions" } +revolt-config = { version = "0.12.0", path = "../config" } +revolt-permissions = { version = "0.12.0", path = "../permissions" } # Utility regex = "1.11" diff --git a/crates/core/models/src/v0/channels.rs b/crates/core/models/src/v0/channels.rs index d8b4e228..d9fe8e14 100644 --- a/crates/core/models/src/v0/channels.rs +++ b/crates/core/models/src/v0/channels.rs @@ -112,6 +112,10 @@ auto_derived!( /// Voice Information for when this channel is also a voice channel #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] voice: Option, + + /// The channel's slowmode delay in seconds + #[serde(skip_serializing_if = "Option::is_none")] + slowmode: Option, }, } @@ -150,6 +154,8 @@ auto_derived!( pub last_message_id: Option, #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] pub voice: Option, + #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))] + pub slowmode: Option, } /// Optional fields on channel object @@ -189,6 +195,10 @@ auto_derived!( /// Voice Information for voice channels pub voice: Option, + /// 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, + /// Fields to remove from channel #[cfg_attr(feature = "serde", serde(default))] pub remove: Vec, diff --git a/crates/core/models/src/v0/files.rs b/crates/core/models/src/v0/files.rs index b996483c..32ccf0e1 100644 --- a/crates/core/models/src/v0/files.rs +++ b/crates/core/models/src/v0/files.rs @@ -46,8 +46,13 @@ auto_derived!( File, /// File contains textual data and should be displayed as such Text, - /// File is an image with specific dimensions - Image { width: usize, height: usize }, + /// File is an image with specific dimensions, and may be animated + Image { + width: usize, + height: usize, + thumbhash: Option>, + animated: bool, + }, /// File is a video with specific dimensions Video { width: usize, height: usize }, /// File is audio diff --git a/crates/core/models/src/v0/messages.rs b/crates/core/models/src/v0/messages.rs index 67af69f8..2b03d211 100644 --- a/crates/core/models/src/v0/messages.rs +++ b/crates/core/models/src/v0/messages.rs @@ -133,7 +133,10 @@ auto_derived!( #[serde(rename = "message_unpinned")] MessageUnpinned { id: String, by: String }, #[serde(rename = "call_started")] - CallStarted { by: String, finished_at: Option }, + CallStarted { + by: String, + finished_at: Option, + }, } /// Name and / or avatar override information @@ -201,6 +204,9 @@ auto_derived!( pub image: Option, /// Message content or system message information pub body: String, + /// The raw body, if the body has been rendered + #[serde(skip_serializing_if = "Option::is_none")] + pub raw_body: Option, /// Unique tag, usually the channel ID pub tag: String, /// Timestamp at which this notification was created @@ -385,6 +391,7 @@ auto_derived!( ); /// Message Author Abstraction +#[derive(Clone)] pub enum MessageAuthor<'a> { User(&'a User), Webhook(&'a Webhook), @@ -508,6 +515,7 @@ impl PushNotification { icon, image, body, + raw_body: None, tag: channel.id().to_string(), timestamp, url: format!("{}/channel/{}/{}", config.hosts.app, channel.id(), msg.id), diff --git a/crates/core/models/src/v0/server_bans.rs b/crates/core/models/src/v0/server_bans.rs index b6debfcd..940a3738 100644 --- a/crates/core/models/src/v0/server_bans.rs +++ b/crates/core/models/src/v0/server_bans.rs @@ -19,6 +19,9 @@ auto_derived!( /// Ban reason #[cfg_attr(feature = "validator", validate(length(min = 0, max = 1024)))] pub reason: Option, + /// Messages to delete in seconds + #[cfg_attr(feature = "validator", validate(range(min = 0, max = 604800)))] + pub delete_message_seconds: Option, } /// Just enough information to list a ban diff --git a/crates/core/models/src/v0/servers.rs b/crates/core/models/src/v0/servers.rs index 0d330334..d9835443 100644 --- a/crates/core/models/src/v0/servers.rs +++ b/crates/core/models/src/v0/servers.rs @@ -252,6 +252,9 @@ auto_derived!( /// Must be enabled in order to show up on [Revolt Discover](https://rvlt.gg). pub analytics: Option, + /// User id of the new owner + pub owner: Option, + /// Fields to remove from server object #[cfg_attr(feature = "serde", serde(default))] pub remove: Vec, diff --git a/crates/core/parser/Cargo.toml b/crates/core/parser/Cargo.toml index ba9d2255..f542b7aa 100644 --- a/crates/core/parser/Cargo.toml +++ b/crates/core/parser/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-parser" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "MIT" authors = ["Zomatree ", "Paul Makles "] diff --git a/crates/core/parser/src/lib.rs b/crates/core/parser/src/lib.rs index 4ebbac63..27211c1b 100644 --- a/crates/core/parser/src/lib.rs +++ b/crates/core/parser/src/lib.rs @@ -16,23 +16,29 @@ pub enum MessageToken<'a> { UserMention(&'a str), #[regex("<%(?&id)>", |lex| &lex.slice()[2..lex.slice().len() - 1],)] RoleMention(&'a str), + #[regex("<#(?&id)>", |lex| &lex.slice()[2..lex.slice().len() - 1],)] + ChannelMention(&'a str), + #[regex(":(?&id):", |lex| &lex.slice()[1..lex.slice().len() - 1],)] + Emoji(&'a str), #[token("@everyone")] MentionEveryone, #[token("@online")] - MentionOnline + MentionOnline, } #[derive(Debug, Clone, PartialEq, Default)] pub struct MessageResults { pub user_mentions: HashSet, pub role_mentions: HashSet, + pub channel_mentions: HashSet, + pub emojis: HashSet, pub mentions_everyone: bool, - pub mentions_online: bool + pub mentions_online: bool, } struct MessageParserIterator<'a, I> { inner: I, - temp: VecDeque> + temp: VecDeque>, } impl<'a, I: Iterator>> Iterator for MessageParserIterator<'a, I> { @@ -55,11 +61,11 @@ impl<'a, I: Iterator>> Iterator for MessageParserIterato if next_token == Some(MessageToken::CodeblockMarker(ty)) { self.temp.clear(); self.temp.push_back(MessageToken::CodeblockMarker(ty)); - break next_token + break next_token; } else if let Some(token) = next_token { self.temp.push_back(token); } else { - break Some(MessageToken::CodeblockMarker(ty)) + break Some(MessageToken::CodeblockMarker(ty)); } } } else { @@ -69,10 +75,10 @@ impl<'a, I: Iterator>> Iterator for MessageParserIterato } } -pub fn parse_message_iter(text: &str) -> impl Iterator + '_ { +pub fn parse_message_iter(text: &str) -> impl Iterator> + '_ { MessageParserIterator { inner: MessageToken::lexer(text).flatten(), - temp: VecDeque::new() + temp: VecDeque::new(), } } @@ -82,13 +88,23 @@ pub fn parse_message(text: &str) -> MessageResults { for token in parse_message_iter(text) { match token { MessageToken::Escape => {} - MessageToken::CodeblockMarker(_) => {}, - MessageToken::UserMention(id) => { results.user_mentions.insert(id.to_string()); }, - MessageToken::RoleMention(id) => { results.role_mentions.insert(id.to_string()); }, + MessageToken::CodeblockMarker(_) => {} + MessageToken::UserMention(id) => { + results.user_mentions.insert(id.to_string()); + } + MessageToken::RoleMention(id) => { + results.role_mentions.insert(id.to_string()); + } + MessageToken::ChannelMention(id) => { + results.channel_mentions.insert(id.to_string()); + } + MessageToken::Emoji(id) => { + results.emojis.insert(id.to_string()); + } MessageToken::MentionEveryone => results.mentions_everyone = true, MessageToken::MentionOnline => results.mentions_online = true, }; - }; + } results } @@ -109,7 +125,10 @@ mod tests { let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>.").collect::>(); assert_eq!(output.len(), 1); - assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); + assert_eq!( + output[0], + MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); } #[test] @@ -117,7 +136,10 @@ mod tests { let output = parse_message_iter("Hello <%01FD58YK5W7QRV5H3D64KTQYX3>.").collect::>(); assert_eq!(output.len(), 1); - assert_eq!(output[0], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); + assert_eq!( + output[0], + MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); } #[test] @@ -138,29 +160,57 @@ mod tests { #[test] fn test_everything() { - let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>, <%01FD58YK5W7QRV5H3D64KTQYX3>, @everyone and @online.").collect::>(); + let output = parse_message_iter("Hello <@01FD58YK5W7QRV5H3D64KTQYX3>, <%01FD58YK5W7QRV5H3D64KTQYX3>, <#01FD58YK5W7QRV5H3D64KTQYX3> @everyone and @online. :01FD58YK5W7QRV5H3D64KTQYX3:").collect::>(); - assert_eq!(output.len(), 4); - assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[1], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[2], MessageToken::MentionEveryone); - assert_eq!(output[3], MessageToken::MentionOnline); + assert_eq!(output.len(), 6); + assert_eq!( + output[0], + MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[1], + MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[2], + MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!(output[3], MessageToken::MentionEveryone); + assert_eq!(output[4], MessageToken::MentionOnline); + assert_eq!(output[5], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3")); } #[test] fn test_everything_no_spaces() { - let output = parse_message_iter("<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online").collect::>(); + let output = parse_message_iter( + "<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:", + ) + .collect::>(); - assert_eq!(output.len(), 4); - assert_eq!(output[0], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[1], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[2], MessageToken::MentionEveryone); - assert_eq!(output[3], MessageToken::MentionOnline); + assert_eq!(output.len(), 6); + assert_eq!( + output[0], + MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[1], + MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[2], + MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!(output[3], MessageToken::MentionEveryone); + assert_eq!(output[4], MessageToken::MentionOnline); + assert_eq!(output[5], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3")); } #[test] fn test_codeblock_no_mentions() { - let output = parse_message_iter("```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online\n```").collect::>(); + let output = parse_message_iter( + "```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:\n```", + ) + .collect::>(); assert_eq!(output.len(), 2); assert_eq!(output[0], MessageToken::CodeblockMarker(3)); @@ -169,19 +219,36 @@ mod tests { #[test] fn test_uncontained_codeblock_should_mention() { - let output = parse_message_iter("```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online").collect::>(); + let output = parse_message_iter( + "```\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:", + ) + .collect::>(); - assert_eq!(output.len(), 5); + assert_eq!(output.len(), 7); assert_eq!(output[0], MessageToken::CodeblockMarker(3)); - assert_eq!(output[1], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[2], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[3], MessageToken::MentionEveryone); - assert_eq!(output[4], MessageToken::MentionOnline); + assert_eq!( + output[1], + MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[2], + MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[3], + MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!(output[4], MessageToken::MentionEveryone); + assert_eq!(output[5], MessageToken::MentionOnline); + assert_eq!(output[6], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3")); } #[test] fn test_inline_codeblock_no_mentions() { - let output = parse_message_iter("`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online`").collect::>(); + let output = parse_message_iter( + "`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:`", + ) + .collect::>(); assert_eq!(output.len(), 2); assert_eq!(output[0], MessageToken::CodeblockMarker(1)); @@ -190,19 +257,33 @@ mod tests { #[test] fn test_uncontained_inline_codeblock_should_mention() { - let output = parse_message_iter("`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online").collect::>(); + let output = parse_message_iter( + "`<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:", + ) + .collect::>(); - assert_eq!(output.len(), 5); + assert_eq!(output.len(), 7); assert_eq!(output[0], MessageToken::CodeblockMarker(1)); - assert_eq!(output[1], MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[2], MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3")); - assert_eq!(output[3], MessageToken::MentionEveryone); - assert_eq!(output[4], MessageToken::MentionOnline); + assert_eq!( + output[1], + MessageToken::UserMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[2], + MessageToken::RoleMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!( + output[3], + MessageToken::ChannelMention("01FD58YK5W7QRV5H3D64KTQYX3") + ); + assert_eq!(output[4], MessageToken::MentionEveryone); + assert_eq!(output[5], MessageToken::MentionOnline); + assert_eq!(output[6], MessageToken::Emoji("01FD58YK5W7QRV5H3D64KTQYX3")); } #[test] fn test_codeblock_with_language_no_mentions() { - let output = parse_message_iter("```rust\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online```").collect::>(); + let output = parse_message_iter("```rust\n<@01FD58YK5W7QRV5H3D64KTQYX3><%01FD58YK5W7QRV5H3D64KTQYX3><#01FD58YK5W7QRV5H3D64KTQYX3>@everyone@online:01FD58YK5W7QRV5H3D64KTQYX3:```").collect::>(); assert_eq!(output.len(), 2); assert_eq!(output[0], MessageToken::CodeblockMarker(3)); @@ -220,7 +301,8 @@ mod tests { #[test] fn test_double_inline_codeblock_with_backticks_inside() { - let output = parse_message_iter("``this `should` not `ping` @everyone``").collect::>(); + let output = + parse_message_iter("``this `should` not `ping` @everyone``").collect::>(); assert_eq!(output.len(), 2); assert_eq!(output[0], MessageToken::CodeblockMarker(2)); @@ -238,7 +320,8 @@ mod tests { #[test] fn test_escaped_codeblock() { - let output = parse_message_iter("i am ~~not~~ pinging \\`@everyone` ok.").collect::>(); + let output = + parse_message_iter("i am ~~not~~ pinging \\`@everyone` ok.").collect::>(); assert_eq!(output.len(), 3); assert_eq!(output[0], MessageToken::Escape); @@ -253,4 +336,4 @@ mod tests { assert_eq!(output.len(), 1); assert_eq!(output[0], MessageToken::Escape); } -} \ No newline at end of file +} diff --git a/crates/core/permissions/Cargo.toml b/crates/core/permissions/Cargo.toml index 86ac095d..98edf99b 100644 --- a/crates/core/permissions/Cargo.toml +++ b/crates/core/permissions/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-permissions" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] @@ -22,7 +22,7 @@ async-std = { version = "1.8.0", features = ["attributes"] } [dependencies] # Core -revolt-result = { version = "0.9.4", path = "../result" } +revolt-result = { version = "0.12.0", path = "../result" } # Utility auto_ops = "0.3.0" diff --git a/crates/core/permissions/src/models/channel.rs b/crates/core/permissions/src/models/channel.rs index 9e916026..fc6fe49a 100644 --- a/crates/core/permissions/src/models/channel.rs +++ b/crates/core/permissions/src/models/channel.rs @@ -75,6 +75,8 @@ pub enum ChannelPermission { Masquerade = 1 << 28, /// React to messages with emojis React = 1 << 29, + /// Bypass slowmode + BypassSlowmode = 1 << 39, // * Voice permissions /// Connect to a voice channel @@ -99,7 +101,7 @@ pub enum ChannelPermission { MentionRoles = 1 << 38, // * Misc. permissions - // % Bits 38 to 52: free area + // % Bits 39 to 52: free area // % Bits 53 to 64: do not use // * Grant all permissions @@ -141,7 +143,7 @@ pub static DEFAULT_PERMISSION: Lazy = Lazy::new(|| { pub static DEFAULT_PERMISSION_SAVED_MESSAGES: u64 = ChannelPermission::GrantAllSafe as u64; pub static DEFAULT_PERMISSION_DIRECT_MESSAGE: Lazy = Lazy::new(|| { - DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React) + DEFAULT_PERMISSION.add(ChannelPermission::ManageChannel + ChannelPermission::React + ChannelPermission::Masquerade) }); pub static DEFAULT_PERMISSION_SERVER: Lazy = Lazy::new(|| { diff --git a/crates/core/presence/Cargo.toml b/crates/core/presence/Cargo.toml index cdbcd590..3b3ce3ee 100644 --- a/crates/core/presence/Cargo.toml +++ b/crates/core/presence/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-presence" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] @@ -17,7 +17,7 @@ redis-is-patched = [] async-std = { version = "1.8.0", features = ["attributes"] } # Config for loading Redis URI -revolt-config = { version = "0.9.4", path = "../config" } +revolt-config = { version = "0.12.0", path = "../config" } [dependencies] # Utility diff --git a/crates/core/ratelimits/Cargo.toml b/crates/core/ratelimits/Cargo.toml index 3447c48c..54c1a702 100644 --- a/crates/core/ratelimits/Cargo.toml +++ b/crates/core/ratelimits/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-ratelimits" -version = "0.9.4" +version = "0.12.0" edition = "2024" license = "MIT" authors = ["Zomatree ", "Paul Makles "] @@ -18,9 +18,9 @@ axum = ["dep:axum", "revolt-database/axum-impl"] default = ["rocket", "axum"] [dependencies] -revolt-database = { version = "0.9.4", path = "../database" } -revolt-result = { version = "0.9.4", path = "../result" } -revolt-config = { version = "0.9.4", path = "../config" } +revolt-database = { version = "0.12.0", path = "../database" } +revolt-result = { version = "0.12.0", path = "../result" } +revolt-config = { version = "0.12.0", path = "../config" } rocket = { version = "0.5.1", optional = true } revolt_rocket_okapi = { version = "0.10.0", optional = true } @@ -28,7 +28,7 @@ revolt_rocket_okapi = { version = "0.10.0", optional = true } axum = { version = "0.7.5", optional = true, features = ["macros"] } serde = { version = "1", features = ["derive"] } -authifier = { version = "1.0.15" } +authifier = { version = "1.0.16" } dashmap = "5.2.0" async-trait = "0.1.81" log = "0.4" diff --git a/crates/core/result/Cargo.toml b/crates/core/result/Cargo.toml index d87a7d1b..0ebcc4e2 100644 --- a/crates/core/result/Cargo.toml +++ b/crates/core/result/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-result" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "MIT" authors = ["Paul Makles "] diff --git a/crates/core/result/src/axum.rs b/crates/core/result/src/axum.rs index 3cbf4359..23b09f7b 100644 --- a/crates/core/result/src/axum.rs +++ b/crates/core/result/src/axum.rs @@ -36,7 +36,11 @@ impl IntoResponse for Error { ErrorType::NotInGroup => StatusCode::NOT_FOUND, ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST, ErrorType::NotPinned => StatusCode::BAD_REQUEST, + ErrorType::InSlowmode { + retry_after: _, + } => StatusCode::TOO_MANY_REQUESTS, + ErrorType::CantCreateServers => StatusCode::FORBIDDEN, ErrorType::UnknownServer => StatusCode::NOT_FOUND, ErrorType::InvalidRole => StatusCode::NOT_FOUND, ErrorType::Banned => StatusCode::FORBIDDEN, diff --git a/crates/core/result/src/lib.rs b/crates/core/result/src/lib.rs index a7661212..e4907a6b 100644 --- a/crates/core/result/src/lib.rs +++ b/crates/core/result/src/lib.rs @@ -1,5 +1,5 @@ -use std::panic::Location; use std::fmt::Display; +use std::panic::Location; #[cfg(feature = "serde")] #[macro_use] @@ -102,8 +102,12 @@ pub enum ErrorType { NotInGroup, AlreadyPinned, NotPinned, + InSlowmode { + retry_after: u64, + }, // ? Server related errors + CantCreateServers, UnknownServer, InvalidRole, Banned, @@ -232,17 +236,16 @@ impl ToRevoltError for Result Result { let loc = Location::caller(); - self - .map_err(|e| { - log::error!("{e:?}"); - #[cfg(feature = "sentry")] - sentry::capture_error(&e); + self.map_err(|e| { + log::error!("{e:?}"); + #[cfg(feature = "sentry")] + sentry::capture_error(&e); - Error { - error_type: ErrorType::InternalError, - location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()) - } - }) + Error { + error_type: ErrorType::InternalError, + location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()), + } + }) } } @@ -251,11 +254,9 @@ impl ToRevoltError for Option { fn to_internal_error(self) -> Result { let loc = Location::caller(); - self.ok_or_else(|| { - Error { - error_type: ErrorType::InternalError, - location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()) - } + self.ok_or_else(|| Error { + error_type: ErrorType::InternalError, + location: format!("{}:{}:{}", loc.file(), loc.line(), loc.column()), }) } } diff --git a/crates/core/result/src/rocket.rs b/crates/core/result/src/rocket.rs index 94bed4d2..5d2904d0 100644 --- a/crates/core/result/src/rocket.rs +++ b/crates/core/result/src/rocket.rs @@ -42,8 +42,12 @@ impl<'r> Responder<'r, 'static> for Error { ErrorType::NotInGroup => Status::NotFound, ErrorType::AlreadyPinned => Status::BadRequest, ErrorType::NotPinned => Status::BadRequest, + ErrorType::InSlowmode { + retry_after: _, + } => Status::TooManyRequests, ErrorType::InvalidFlagValue => Status::BadRequest, + ErrorType::CantCreateServers => Status::Forbidden, ErrorType::UnknownServer => Status::NotFound, ErrorType::InvalidRole => Status::NotFound, ErrorType::Banned => Status::Forbidden, diff --git a/crates/daemons/crond/Cargo.toml b/crates/daemons/crond/Cargo.toml index 762ce469..1f9de2b0 100644 --- a/crates/daemons/crond/Cargo.toml +++ b/crates/daemons/crond/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-crond" -version = "0.9.4" +version = "0.12.0" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2021" @@ -17,7 +17,7 @@ log = "0.4" tokio = { version = "1" } # Core -revolt-database = { version = "0.9.4", path = "../../core/database" } -revolt-result = { version = "0.9.4", path = "../../core/result" } -revolt-config = { version = "0.9.4", path = "../../core/config" } -revolt-files = { version = "0.9.4", path = "../../core/files" } +revolt-database = { version = "0.12.0", path = "../../core/database" } +revolt-result = { version = "0.12.0", path = "../../core/result" } +revolt-config = { version = "0.12.0", path = "../../core/config" } +revolt-files = { version = "0.12.0", path = "../../core/files" } diff --git a/crates/daemons/crond/Dockerfile b/crates/daemons/crond/Dockerfile index c1bdf80f..f1852457 100644 --- a/crates/daemons/crond/Dockerfile +++ b/crates/daemons/crond/Dockerfile @@ -5,6 +5,7 @@ FROM debian:12 AS debian # Bundle Stage FROM gcr.io/distroless/cc-debian12:nonroot COPY --from=builder /home/rust/src/target/release/revolt-crond ./ +COPY --from=debian /usr/bin/uname /usr/bin/uname USER nonroot CMD ["./revolt-crond"] \ No newline at end of file diff --git a/crates/daemons/pushd/Cargo.toml b/crates/daemons/pushd/Cargo.toml index 59e2b1c7..95e13fb2 100644 --- a/crates/daemons/pushd/Cargo.toml +++ b/crates/daemons/pushd/Cargo.toml @@ -1,23 +1,24 @@ [package] name = "revolt-pushd" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false [dependencies] -revolt-result = { version = "0.9.4", path = "../../core/result" } -revolt-config = { version = "0.9.4", path = "../../core/config", features = [ +revolt-result = { version = "0.12.0", path = "../../core/result" } +revolt-config = { version = "0.12.0", path = "../../core/config", features = [ "report-macros", - "anyhow" + "anyhow", ] } -revolt-database = { version = "0.9.4", path = "../../core/database" } -revolt-models = { version = "0.9.4", path = "../../core/models", features = [ +revolt-database = { version = "0.12.0", path = "../../core/database" } +revolt-models = { version = "0.12.0", path = "../../core/models", features = [ "validator", ] } -revolt-presence = { version = "0.9.4", path = "../../core/presence", features = [ +revolt-presence = { version = "0.12.0", path = "../../core/presence", features = [ "redis-is-patched", ] } +revolt-parser = { version = "0.12.0", path = "../../core/parser" } anyhow = { version = "1.0.98" } @@ -26,15 +27,18 @@ fcm_v1 = "0.3.0" web-push = "0.10.0" isahc = { optional = true, version = "1.7", features = ["json"] } revolt_a2 = { version = "0.10", default-features = false, features = ["ring"] } +redis-kiss = "0.1.4" tokio = "1.39.2" async-trait = "0.1.81" ulid = "1.0.0" -authifier = "1.0.15" +authifier = "1.0.16" log = "0.4.11" pretty_env_logger = "0.4.0" +regex = "1.12.3" + #serialization serde_json = "1" revolt_optional_struct = "0.2.0" diff --git a/crates/daemons/pushd/src/consumers/inbound/dm_call.rs b/crates/daemons/pushd/src/consumers/inbound/dm_call.rs index b5b89dc1..873b55cc 100644 --- a/crates/daemons/pushd/src/consumers/inbound/dm_call.rs +++ b/crates/daemons/pushd/src/consumers/inbound/dm_call.rs @@ -128,7 +128,7 @@ impl DmCallConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs b/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs index d525138c..115e4a26 100644 --- a/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs +++ b/crates/daemons/pushd/src/consumers/inbound/fr_accepted.rs @@ -101,7 +101,7 @@ impl FRAcceptedConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/fr_received.rs b/crates/daemons/pushd/src/consumers/inbound/fr_received.rs index f64bc92b..c611dfaf 100644 --- a/crates/daemons/pushd/src/consumers/inbound/fr_received.rs +++ b/crates/daemons/pushd/src/consumers/inbound/fr_received.rs @@ -101,7 +101,7 @@ impl FRReceivedConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/generic.rs b/crates/daemons/pushd/src/consumers/inbound/generic.rs index aa3950d7..302b1700 100644 --- a/crates/daemons/pushd/src/consumers/inbound/generic.rs +++ b/crates/daemons/pushd/src/consumers/inbound/generic.rs @@ -107,7 +107,7 @@ impl GenericConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs b/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs index bf5dd453..8d43cce8 100644 --- a/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs +++ b/crates/daemons/pushd/src/consumers/inbound/mass_mention.rs @@ -3,7 +3,7 @@ use std::{ hash::RandomState, }; -use crate::consumers::inbound::internal::*; +use crate::{consumers::inbound::internal::*, utils}; use amqprs::{ channel::{BasicPublishArguments, Channel}, connection::Connection, @@ -17,6 +17,7 @@ use revolt_database::{ MessageFlagsValue, }; use revolt_models::v0::{MessageFlags, PushNotification}; +use revolt_result::ToRevoltError; pub struct MassMessageConsumer { #[allow(dead_code)] @@ -104,7 +105,7 @@ impl MassMessageConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); @@ -129,7 +130,17 @@ impl MassMessageConsumer { ) -> Result<()> { let config = revolt_config::config().await; let content = String::from_utf8(content)?; - let payload: MassMessageSentPayload = serde_json::from_str(content.as_str())?; + let mut payload: MassMessageSentPayload = serde_json::from_str(content.as_str())?; + + for push in payload.notifications.iter_mut() { + if let Ok(body) = utils::render_notification_content(push, &self.db) + .await + .to_internal_error() + { + push.raw_body = Some(push.body.clone()); + push.body = body; + } + } debug!("Received mass message event"); diff --git a/crates/daemons/pushd/src/consumers/inbound/message.rs b/crates/daemons/pushd/src/consumers/inbound/message.rs index 6b9d00fe..45de0b0b 100644 --- a/crates/daemons/pushd/src/consumers/inbound/message.rs +++ b/crates/daemons/pushd/src/consumers/inbound/message.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::consumers::inbound::internal::*; +use crate::{consumers::inbound::internal::*, utils}; use amqprs::{ channel::{BasicPublishArguments, Channel}, connection::Connection, @@ -11,6 +11,7 @@ use anyhow::Result; use async_trait::async_trait; use log::debug; use revolt_database::{events::rabbit::*, Database}; +use revolt_result::ToRevoltError; pub struct MessageConsumer { #[allow(dead_code)] @@ -64,7 +65,15 @@ impl MessageConsumer { content: Vec, ) -> Result<()> { let content = String::from_utf8(content)?; - let payload: MessageSentPayload = serde_json::from_str(content.as_str())?; + let mut payload: MessageSentPayload = serde_json::from_str(content.as_str())?; + + if let Ok(body) = utils::render_notification_content(&payload.notification, &self.db) + .await + .to_internal_error() + { + payload.notification.raw_body = Some(payload.notification.body); + payload.notification.body = body; + } debug!("Received message event on origin"); @@ -107,7 +116,7 @@ impl MessageConsumer { config.pushd.vapid.queue.as_str(), ) .finish(); - sendable.extras.insert("p265dh".to_string(), sub.p256dh); + sendable.extras.insert("p256dh".to_string(), sub.p256dh); sendable .extras .insert("endpoint".to_string(), sub.endpoint.clone()); diff --git a/crates/daemons/pushd/src/main.rs b/crates/daemons/pushd/src/main.rs index 472caafe..eaa2e1b8 100644 --- a/crates/daemons/pushd/src/main.rs +++ b/crates/daemons/pushd/src/main.rs @@ -14,6 +14,7 @@ use revolt_config::{config, Settings}; use tokio::sync::Notify; mod consumers; +mod utils; use consumers::{ inbound::{ ack::AckConsumer, dm_call::DmCallConsumer, fr_accepted::FRAcceptedConsumer, diff --git a/crates/daemons/pushd/src/utils/mod.rs b/crates/daemons/pushd/src/utils/mod.rs new file mode 100644 index 00000000..62032e80 --- /dev/null +++ b/crates/daemons/pushd/src/utils/mod.rs @@ -0,0 +1,2 @@ +mod renderer; +pub use renderer::render_notification_content; diff --git a/crates/daemons/pushd/src/utils/renderer.rs b/crates/daemons/pushd/src/utils/renderer.rs new file mode 100644 index 00000000..ef16c9d8 --- /dev/null +++ b/crates/daemons/pushd/src/utils/renderer.rs @@ -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 { + _get_conn().await.map_err(|_| create_error!(InternalError)) +} + +pub async fn render_notification_content( + notification: &PushNotification, + db: &Database, +) -> Result { + let parsed = parse_message(¬ification.body); + + let user_mentions: HashMap; + let channel_mentions: HashMap; + let emojis: HashMap; + let roles: HashMap; + + let server_id: Option = 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( + items: HashSet, + server_id: Option<&str>, + db: &Database, + getter: F, + invalid_string: String, +) -> HashMap +where + F: AsyncFn(&str, Option<&str>, &Database) -> Result, +{ + 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 { + 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 { + 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 { + 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 { + 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 { + 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(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); + } +} diff --git a/crates/daemons/voice-ingress/Cargo.toml b/crates/daemons/voice-ingress/Cargo.toml index 6e60f765..0b498d93 100644 --- a/crates/daemons/voice-ingress/Cargo.toml +++ b/crates/daemons/voice-ingress/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-voice-ingress" -version = "0.9.4" +version = "0.12.0" license = "AGPL-3.0-or-later" edition = "2021" publish = false diff --git a/crates/daemons/voice-ingress/src/api.rs b/crates/daemons/voice-ingress/src/api.rs index 2af3ab26..dbe8934e 100644 --- a/crates/daemons/voice-ingress/src/api.rs +++ b/crates/daemons/voice-ingress/src/api.rs @@ -5,9 +5,9 @@ use revolt_database::{ iso8601_timestamp::{Duration, Timestamp}, util::reference::Reference, voice::{ - create_voice_state, delete_voice_state, - get_user_moved_from_voice, get_user_moved_to_voice, - update_voice_state_tracks, VoiceClient, + create_voice_state, delete_channel_voice_state, delete_voice_state, + get_user_moved_from_voice, get_user_moved_to_voice, update_voice_state_tracks, + RoomMetadata, UserVoiceChannel, VoiceClient, }, Database, AMQP, }; @@ -51,27 +51,34 @@ pub async fn ingress( let channel_id = event.room.as_ref().map(|r| &r.name); let user_id = event.participant.as_ref().map(|r| &r.identity); + let room_metadata = if let Some(room) = event.room.as_ref() { + Some(serde_json::from_str::(&room.metadata).to_internal_error()?) + } else { + None + }; match event.event.as_str() { // User joined a channel "participant_joined" => { let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; - - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; let joined_at = Timestamp::UNIX_EPOCH .checked_add(Duration::seconds(event.created_at)) .unwrap(); - let voice_state = - create_voice_state(channel_id, channel.server(), user_id, joined_at).await?; + let voice_state = create_voice_state(&channel, user_id, joined_at).await?; // Only publish one event when a user is moved from one channel to another. if let Some(moved_from) = get_user_moved_to_voice(channel_id, user_id).await? { EventV1::VoiceChannelMove { user: user_id.to_string(), - from: moved_from, + from: moved_from.id, to: channel_id.to_string(), state: voice_state, } @@ -136,10 +143,13 @@ pub async fn ingress( "participant_left" => { let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; - - delete_voice_state(channel_id, channel.server(), user_id).await?; + delete_voice_state(&channel, user_id).await?; // Dont send leave event when a user is moved if get_user_moved_from_voice(channel_id, user_id) @@ -203,8 +213,11 @@ pub async fn ingress( let channel_id = channel_id.to_internal_error()?; let user_id = user_id.to_internal_error()?; let track = event.track.as_ref().to_internal_error()?; - - let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; let user = Reference::from_unchecked(user_id).as_user(db).await?; @@ -244,15 +257,14 @@ pub async fn ingress( log::debug!("Removing user {user_id} from channel {channel_id} {event:?} due to forbidden track."); let _ = voice_client.remove_user(node, user_id, channel_id).await; - delete_voice_state(channel_id, channel.server(), user_id).await?; + delete_voice_state(&channel, user_id).await?; return Ok(EmptyResponse); }; }; let partial = update_voice_state_tracks( - channel_id, - channel.server(), + &channel, user_id, event.event == "track_published" || event.event == "track_unmuted", // to avoid duplicating this entire case twice track.source, @@ -267,6 +279,16 @@ pub async fn ingress( .p(channel_id.clone()) .await; } + "room_finished" => { + let channel_id = channel_id.to_internal_error()?; + let server_id = room_metadata.to_internal_error()?.server; + let channel = UserVoiceChannel { + id: channel_id.clone(), + server_id: server_id.clone(), + }; + + delete_channel_voice_state(&channel, &[]).await?; + } _ => {} }; diff --git a/crates/delta/Cargo.toml b/crates/delta/Cargo.toml index 895815aa..7976d208 100644 --- a/crates/delta/Cargo.toml +++ b/crates/delta/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-delta" -version = "0.9.4" +version = "0.12.0" license = "AGPL-3.0-or-later" authors = ["Paul Makles "] edition = "2018" @@ -57,7 +57,7 @@ lettre = "0.10.0-alpha.4" rocket = { version = "0.5.1", default-features = false, features = ["json"] } rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" } rocket_empty = { version = "0.1.1", features = ["schema"] } -rocket_authifier = { version = "1.0.15" } +rocket_authifier = { version = "1.0.16" } rocket_prometheus = "0.10.0-rc.3" # spec generation @@ -68,7 +68,7 @@ revolt_rocket_okapi = { version = "0.10.0", features = ["swagger"] } amqprs = { version = "1.7.0" } # core -authifier = "1.0.15" +authifier = "1.0.16" revolt-config = { path = "../core/config" } revolt-database = { path = "../core/database", features = [ "rocket-impl", diff --git a/crates/delta/src/routes/bots/delete.rs b/crates/delta/src/routes/bots/delete.rs index 73a00156..c3c89fcc 100644 --- a/crates/delta/src/routes/bots/delete.rs +++ b/crates/delta/src/routes/bots/delete.rs @@ -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 rocket::State; use rocket_empty::EmptyResponse; @@ -7,21 +11,21 @@ use rocket_empty::EmptyResponse; /// /// Delete a bot by its id. #[openapi(tag = "Bots")] -#[delete("/")] +#[delete("/")] pub async fn delete_bot( db: &State, voice_client: &State, user: User, - target: Reference<'_>, + bot_id: Reference<'_>, ) -> Result { - let bot = target.as_bot(db).await?; + let bot = bot_id.as_bot(db).await?; if bot.owner != user.id { return Err(create_error!(NotFound)); } 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) } diff --git a/crates/delta/src/routes/bots/edit.rs b/crates/delta/src/routes/bots/edit.rs index 6c454e13..a142ba46 100644 --- a/crates/delta/src/routes/bots/edit.rs +++ b/crates/delta/src/routes/bots/edit.rs @@ -10,11 +10,11 @@ use validator::Validate; /// /// Edit bot details by its id. #[openapi(tag = "Bots")] -#[patch("/", data = "")] +#[patch("/", data = "")] pub async fn edit_bot( db: &State, user: User, - target: Reference<'_>, + bot_id: Reference<'_>, data: Json, ) -> Result> { 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 { return Err(create_error!(NotFound)); } diff --git a/crates/delta/src/routes/bots/fetch.rs b/crates/delta/src/routes/bots/fetch.rs index 586ce124..344620cf 100644 --- a/crates/delta/src/routes/bots/fetch.rs +++ b/crates/delta/src/routes/bots/fetch.rs @@ -7,17 +7,17 @@ use rocket::{serde::json::Json, State}; /// /// Fetch details of a bot you own by its id. #[openapi(tag = "Bots")] -#[get("/")] +#[get("/")] pub async fn fetch_bot( db: &State, user: User, - bot: Reference<'_>, + bot_id: Reference<'_>, ) -> Result> { if user.bot.is_some() { 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 { return Err(create_error!(NotFound)); } diff --git a/crates/delta/src/routes/channels/channel_delete.rs b/crates/delta/src/routes/channels/channel_delete.rs index f6cc8b75..82ed4137 100644 --- a/crates/delta/src/routes/channels/channel_delete.rs +++ b/crates/delta/src/routes/channels/channel_delete.rs @@ -1,7 +1,10 @@ use revolt_database::{ - AMQP, Channel, Database, PartialChannel, User, util::{permissions::DatabasePermissionQuery, reference::Reference}, voice::{ - VoiceClient, delete_voice_channel, is_in_voice_channel, remove_user_from_voice_channel - } + util::{permissions::DatabasePermissionQuery, reference::Reference}, + 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_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -54,15 +57,17 @@ pub async fn delete( ) .await?; - if is_in_voice_channel(&user.id, channel.id()).await? { - remove_user_from_voice_channel(db, voice_client, channel.id(), &user.id).await?; + let user_voice_channel = UserVoiceChannel::from_channel(&channel); + + 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 { .. } => { permissions.throw_if_lacking_channel_permission(ChannelPermission::ManageChannel)?; channel.delete(db).await?; - delete_voice_channel(voice_client, channel.id(), channel.server()).await?; + delete_voice_channel(voice_client, &UserVoiceChannel::from_channel(&channel)).await?; } }; diff --git a/crates/delta/src/routes/channels/channel_edit.rs b/crates/delta/src/routes/channels/channel_edit.rs index 64b3b06a..25ead30b 100644 --- a/crates/delta/src/routes/channels/channel_edit.rs +++ b/crates/delta/src/routes/channels/channel_edit.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - voice::{delete_voice_channel, VoiceClient}, + voice::{delete_voice_channel, UserVoiceChannel, VoiceClient}, Channel, Database, File, PartialChannel, SystemMessage, User, AMQP, }; use revolt_models::v0; @@ -41,6 +41,7 @@ pub async fn edit( && data.nsfw.is_none() && data.owner.is_none() && data.voice.is_none() + && data.slowmode.is_none() && data.remove.is_empty() { return Ok(Json(channel.into())); @@ -200,6 +201,7 @@ pub async fn edit( icon, nsfw, voice, + slowmode, .. } => { if data.remove.contains(&v0::FieldsChannel::Icon) { @@ -247,6 +249,11 @@ pub async fn edit( *voice = Some(new_voice.clone().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)), }; @@ -260,7 +267,7 @@ pub async fn edit( .await?; 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())) diff --git a/crates/delta/src/routes/channels/group_remove_member.rs b/crates/delta/src/routes/channels/group_remove_member.rs index 995991f9..05221629 100644 --- a/crates/delta/src/routes/channels/group_remove_member.rs +++ b/crates/delta/src/routes/channels/group_remove_member.rs @@ -1,7 +1,5 @@ use revolt_database::{ - util::reference::Reference, - voice::{is_in_voice_channel, remove_user_from_voice_channel, VoiceClient}, - Channel, Database, User, AMQP, + AMQP, Channel, Database, User, util::reference::Reference, voice::{UserVoiceChannel, VoiceClient, is_in_voice_channel, remove_user_from_voice_channel} }; use revolt_permissions::ChannelPermission; use revolt_result::{create_error, Result}; @@ -13,20 +11,20 @@ use rocket_empty::EmptyResponse; /// /// Removes a user from the group. #[openapi(tag = "Groups")] -#[delete("//recipients/")] +#[delete("//recipients/")] pub async fn remove_member( db: &State, voice_client: &State, amqp: &State, user: User, - target: Reference<'_>, - member: Reference<'_>, + group_id: Reference<'_>, + member_id: Reference<'_>, ) -> Result { if user.bot.is_some() { return Err(create_error!(IsBot)); } - let channel = target.as_channel(db).await?; + let channel = group_id.as_channel(db).await?; if let Channel::Group { 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 { return Err(create_error!(CannotRemoveYourself)); } @@ -54,8 +52,10 @@ pub async fn remove_member( return Err(create_error!(InvalidOperation)); }; - if is_in_voice_channel(&member.id, channel.id()).await? { - remove_user_from_voice_channel(db, voice_client, channel.id(), &member.id).await?; + let user_voice_channel = UserVoiceChannel::from_channel(&channel); + + 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) diff --git a/crates/delta/src/routes/channels/message_send.rs b/crates/delta/src/routes/channels/message_send.rs index d0479fa1..51dd5713 100644 --- a/crates/delta/src/routes/channels/message_send.rs +++ b/crates/delta/src/routes/channels/message_send.rs @@ -1,9 +1,10 @@ use chrono::{Duration, Utc}; +use redis_kiss::{get_connection, redis, AsyncCommands}; use revolt_database::util::permissions::DatabasePermissionQuery; use revolt_database::{ 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_permissions::PermissionQuery; use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; @@ -57,6 +58,50 @@ pub async fn message_send( 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 = 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 if let Some(interactions) = &data.interactions { let interactions: Interactions = interactions.clone().into(); @@ -186,6 +231,7 @@ mod test { }), last_message_id: None, voice: None, + slowmode: None, }; locked_channel .update(&harness.db, partial, vec![]) diff --git a/crates/delta/src/routes/channels/voice_join.rs b/crates/delta/src/routes/channels/voice_join.rs index 670c5032..0d8aecdd 100644 --- a/crates/delta/src/routes/channels/voice_join.rs +++ b/crates/delta/src/routes/channels/voice_join.rs @@ -3,7 +3,8 @@ use revolt_database::{ util::{permissions::perms, reference::Reference}, voice::{ 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, }; @@ -50,7 +51,9 @@ pub async fn call( let current_permissions = calculate_channel_permissions(&mut permissions).await; 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? .zip(voice_info.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 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 .or(node) @@ -78,20 +82,18 @@ pub async fn call( // Finds and disconnects any existing voice connections by the user, // should only ever loop once but just to cover our backs. - for channel_id in get_user_voice_channels(&user.id).await? { - if let Some(node) = get_channel_node(&channel_id).await? { + for previous_channel in get_user_voice_channels(&user.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 - 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) - .as_channel(db) - .await?; - - delete_voice_state(&channel_id, channel.server(), &user.id).await?; + delete_voice_state(&previous_channel, &user.id).await?; } } else { - raise_if_in_voice(&user, channel.id()).await?; + raise_if_in_voice(&user, &user_voice_channel).await?; } let token = voice_client @@ -100,6 +102,10 @@ pub async fn call( 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); if let Some(recipients) = recipients { diff --git a/crates/delta/src/routes/channels/voice_stop_ring.rs b/crates/delta/src/routes/channels/voice_stop_ring.rs index f8db8fc4..768b861a 100644 --- a/crates/delta/src/routes/channels/voice_stop_ring.rs +++ b/crates/delta/src/routes/channels/voice_stop_ring.rs @@ -1,6 +1,6 @@ use revolt_database::{ util::reference::Reference, - voice::{get_voice_state, VoiceClient}, + voice::{get_voice_state, UserVoiceChannel, VoiceClient}, Channel, Database, User, AMQP, }; use revolt_result::{create_error, Result, ToRevoltError}; @@ -32,9 +32,15 @@ pub async fn stop_ring( return Err(create_error!(NoEffect)); } - if get_voice_state(channel.id(), None, &user.id) - .await? - .is_none() + if get_voice_state( + &UserVoiceChannel { + id: channel.id().to_string(), + server_id: None, + }, + &user.id, + ) + .await? + .is_none() { return Err(create_error!(NotConnected)); } @@ -46,7 +52,7 @@ pub async fn stop_ring( _ => 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 .dm_call_updated( &user.id, diff --git a/crates/delta/src/routes/channels/webhook_create.rs b/crates/delta/src/routes/channels/webhook_create.rs index 71f8e8de..dc8cad64 100644 --- a/crates/delta/src/routes/channels/webhook_create.rs +++ b/crates/delta/src/routes/channels/webhook_create.rs @@ -15,11 +15,11 @@ use validator::Validate; /// /// Creates a webhook which 3rd party platforms can use to send messages #[openapi(tag = "Webhooks")] -#[post("//webhooks", data = "")] +#[post("//webhooks", data = "")] pub async fn create_webhook( db: &State, user: User, - target: Reference<'_>, + channel_id: Reference<'_>, data: Json, ) -> Result> { 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 { .. }) { return Err(create_error!(InvalidOperation)); diff --git a/crates/delta/src/routes/customisation/emoji_create.rs b/crates/delta/src/routes/customisation/emoji_create.rs index d8288dae..52b5c904 100644 --- a/crates/delta/src/routes/customisation/emoji_create.rs +++ b/crates/delta/src/routes/customisation/emoji_create.rs @@ -11,11 +11,11 @@ use rocket::{serde::json::Json, State}; /// /// Create an emoji by its Autumn upload id. #[openapi(tag = "Emojis")] -#[put("/emoji/", data = "")] +#[put("/emoji/", data = "")] pub async fn create_emoji( db: &State, user: User, - id: String, + emoji_id: String, data: Json, ) -> Result> { let config = config().await; @@ -50,11 +50,11 @@ pub async fn create_emoji( }; // 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 let emoji = Emoji { - id, + id: emoji_id, parent: data.parent.into(), creator_id: user.id, name: data.name, diff --git a/crates/delta/src/routes/mod.rs b/crates/delta/src/routes/mod.rs index 9643a365..cf1c4ace 100644 --- a/crates/delta/src/routes/mod.rs +++ b/crates/delta/src/routes/mod.rs @@ -216,7 +216,7 @@ fn custom_openapi_spec() -> OpenApi { }), license: Some(License { 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() }), version: env!("CARGO_PKG_VERSION").to_string(), diff --git a/crates/delta/src/routes/root.rs b/crates/delta/src/routes/root.rs index 3ddbfcc7..6fd37a19 100644 --- a/crates/delta/src/routes/root.rs +++ b/crates/delta/src/routes/root.rs @@ -2,6 +2,7 @@ use revolt_config::config; use revolt_result::Result; use rocket::serde::json::Json; use serde::Serialize; +use std::collections::HashMap; /// # hCaptcha Configuration #[derive(Serialize, JsonSchema, Debug)] @@ -54,6 +55,87 @@ pub struct RevoltFeatures { pub january: Feature, /// Voice server configuration 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, +} + +/// # 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, +} + +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 @@ -134,6 +216,25 @@ pub async fn root() -> Result> { }) .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, app: config.hosts.app, diff --git a/crates/delta/src/routes/servers/ban_create.rs b/crates/delta/src/routes/servers/ban_create.rs index 17589dad..a29b4300 100644 --- a/crates/delta/src/routes/servers/ban_create.rs +++ b/crates/delta/src/routes/servers/ban_create.rs @@ -1,13 +1,19 @@ use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, - voice::{get_user_voice_channel_in_server, remove_user_from_voice_channel, VoiceClient}, - Database, RemovalIntention, ServerBan, User, + voice::{ + get_user_voice_channel_in_server, remove_user_from_voice_channel, UserVoiceChannel, + VoiceClient, + }, + Database, Message, RemovalIntention, ServerBan, User, }; use revolt_models::v0; +use std::time::{Duration, SystemTime}; +use revolt_database::events::client::EventV1; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; use rocket::{serde::json::Json, State}; +use ulid::Ulid; use validator::Validate; /// # Ban User @@ -58,11 +64,27 @@ pub async fn ban( .await?; // 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? { - remove_user_from_voice_channel(db, voice_client, &channel_id, &target.id).await?; + if let Some(channel_id) = get_user_voice_channel_in_server(target.id, &server.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) .await .map(Into::into) diff --git a/crates/delta/src/routes/servers/member_edit.rs b/crates/delta/src/routes/servers/member_edit.rs index ecc1d919..e3f63e49 100644 --- a/crates/delta/src/routes/servers/member_edit.rs +++ b/crates/delta/src/routes/servers/member_edit.rs @@ -9,15 +9,13 @@ use revolt_database::{ voice::{ 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, - VoiceClient, + UserVoiceChannel, VoiceClient, }, Database, File, PartialMember, User, }; use revolt_models::v0::{self, FieldsMember}; -use revolt_permissions::{ - calculate_channel_permissions, calculate_server_permissions, ChannelPermission, -}; +use revolt_permissions::{calculate_channel_permissions, calculate_server_permissions, ChannelPermission, UserPermission}; use revolt_result::{create_error, Result}; use rocket::{form::validate::Contains, serde::json::Json, State}; use validator::Validate; @@ -26,13 +24,13 @@ use validator::Validate; /// /// Edit a member by their id. #[openapi(tag = "Server Members")] -#[patch("//members/", data = "")] +#[patch("//members/", data = "")] pub async fn edit( db: &State, voice_client: &State, user: User, - server: Reference<'_>, - member: Reference<'_>, + server_id: Reference<'_>, + member_id: Reference<'_>, data: Json, ) -> Result> { let data = data.into_inner(); @@ -43,16 +41,18 @@ pub async fn edit( })?; // Fetch server and member - let mut server = server.as_server(db).await?; - let target_user = member.as_user(db).await?; - let mut member = member.as_member(db, &server.id).await?; + let mut server = server_id.as_server(db).await?; + let target_user = member_id.as_user(db).await?; + let mut member = member_id.as_member(db, &server.id).await?; // Fetch our currrent permissions let mut query = DatabasePermissionQuery::new(db, &user).server(&server); let permissions = calculate_server_permissions(&mut query).await; // 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; // Check permissions in server @@ -67,8 +67,10 @@ pub async fn edit( if data.avatar.is_some() || data.remove.contains(&v0::FieldsMember::Avatar) { if user.id == member.id.user { 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 { - 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) { - 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) { - return Err(create_error!(InvalidOperation)) + return Err(create_error!(InvalidOperation)); } 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?; - set_user_moved_to_voice(new_voice_channel.id(), &channel, &target_user.id).await?; + let new_user_voice_channel = UserVoiceChannel::from_channel(&new_voice_channel); + 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 permissions = calculate_channel_permissions(&mut query).await; diff --git a/crates/delta/src/routes/servers/member_fetch.rs b/crates/delta/src/routes/servers/member_fetch.rs index c05935be..967f9814 100644 --- a/crates/delta/src/routes/servers/member_fetch.rs +++ b/crates/delta/src/routes/servers/member_fetch.rs @@ -11,21 +11,21 @@ use rocket::{serde::json::Json, State}; /// /// Retrieve a member. #[openapi(tag = "Server Members")] -#[get("//members/?")] +#[get("//members/?")] pub async fn fetch( db: &State, user: User, - target: Reference<'_>, - member: Reference<'_>, + server_id: Reference<'_>, + member_id: Reference<'_>, roles: Option, ) -> Result> { - let server = target.as_server(db).await?; + let server = server_id.as_server(db).await?; let mut query = DatabasePermissionQuery::new(db, &user).server(&server); if !query.are_we_a_member().await { 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 { Ok(Json(v0::MemberResponse::MemberWithRoles { roles: server diff --git a/crates/delta/src/routes/servers/member_remove.rs b/crates/delta/src/routes/servers/member_remove.rs index 510da42e..1885c5ba 100644 --- a/crates/delta/src/routes/servers/member_remove.rs +++ b/crates/delta/src/routes/servers/member_remove.rs @@ -1,6 +1,9 @@ use revolt_database::{ 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, }; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; @@ -12,21 +15,21 @@ use rocket_empty::EmptyResponse; /// /// Removes a member from the server. #[openapi(tag = "Server Members")] -#[delete("//members/")] +#[delete("//members/")] pub async fn kick( db: &State, voice_client: &State, user: User, - target: Reference<'_>, - member: Reference<'_>, + server_id: Reference<'_>, + member_id: Reference<'_>, ) -> Result { - 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)); } - if member.id == server.owner { + if member_id.id == server.owner { return Err(create_error!(InvalidOperation)); } @@ -35,7 +38,7 @@ pub async fn kick( .await .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()) <= query.get_member_rank().unwrap_or(i64::MIN) { @@ -46,8 +49,16 @@ pub async fn kick( .remove(db, &server, RemovalIntention::Kick, false) .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?; + if let Some(channel_id) = get_user_voice_channel_in_server(member_id.id, &server.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) diff --git a/crates/delta/src/routes/servers/server_create.rs b/crates/delta/src/routes/servers/server_create.rs index 82f0a8aa..5b9aa010 100644 --- a/crates/delta/src/routes/servers/server_create.rs +++ b/crates/delta/src/routes/servers/server_create.rs @@ -1,3 +1,4 @@ +use revolt_config::config; use revolt_database::{Database, Member, Server, User}; use revolt_models::v0; use revolt_result::{create_error, Result}; @@ -20,6 +21,24 @@ pub async fn create_server( 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(); data.validate().map_err(|error| { create_error!(FailedValidation { diff --git a/crates/delta/src/routes/servers/server_delete.rs b/crates/delta/src/routes/servers/server_delete.rs index 475765ca..b99f172b 100644 --- a/crates/delta/src/routes/servers/server_delete.rs +++ b/crates/delta/src/routes/servers/server_delete.rs @@ -2,7 +2,7 @@ use revolt_database::{ util::reference::Reference, voice::{ delete_voice_channel, get_user_voice_channel_in_server, remove_user_from_voice_channel, - VoiceClient, + UserVoiceChannel, VoiceClient, }, Database, RemovalIntention, User, }; @@ -29,13 +29,28 @@ pub async fn delete( if server.owner == user.id { 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 } else { 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 diff --git a/crates/delta/src/routes/servers/server_edit.rs b/crates/delta/src/routes/servers/server_edit.rs index 23d79c34..986c3b08 100644 --- a/crates/delta/src/routes/servers/server_edit.rs +++ b/crates/delta/src/routes/servers/server_edit.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; +use authifier::models::{totp::Totp, Account, ValidatedTicket}; use revolt_database::{ util::{permissions::DatabasePermissionQuery, reference::Reference}, Database, File, PartialServer, User, @@ -7,7 +8,7 @@ use revolt_database::{ use revolt_models::v0; use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_result::{create_error, Result}; -use rocket::{serde::json::Json, State}; +use rocket::{serde::json::Json, Request, State}; use validator::Validate; /// # Edit Server @@ -17,9 +18,11 @@ use validator::Validate; #[patch("/", data = "")] pub async fn edit( db: &State, + account: Account, user: User, target: Reference<'_>, data: Json, + validated_ticket: Option, ) -> Result> { let data = data.into_inner(); data.validate().map_err(|error| { @@ -43,6 +46,7 @@ pub async fn edit( && data.flags.is_none() && data.analytics.is_none() && data.discoverable.is_none() + && data.owner.is_none() && data.remove.is_empty() { return Ok(Json(server.into())); @@ -57,6 +61,17 @@ pub async fn edit( 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 if (data.flags.is_some() /*|| data.nsfw.is_some()*/ || data.discoverable.is_some()) && !user.privileged @@ -80,6 +95,7 @@ pub async fn edit( // nsfw, discoverable, analytics, + owner, remove, } = data; @@ -92,6 +108,7 @@ pub async fn edit( // nsfw, discoverable, analytics, + owner: owner.clone(), ..Default::default() }; @@ -146,6 +163,21 @@ pub async fn edit( 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 .update(db, partial, remove.into_iter().map(Into::into).collect()) .await?; diff --git a/crates/delta/src/routes/webhooks/webhook_execute_github.rs b/crates/delta/src/routes/webhooks/webhook_execute_github.rs index 124b2e97..049392d2 100644 --- a/crates/delta/src/routes/webhooks/webhook_execute_github.rs +++ b/crates/delta/src/routes/webhooks/webhook_execute_github.rs @@ -688,11 +688,23 @@ const LIGHT_ORANGE: &str = "#d9916d"; // for future use // const WHITE: &str = "#c3e1c3"; -fn shorten_text(text: &str, length: usize) -> String { - if text.len() < length { - text.to_string() +fn shorten_single_line_text(text: &str, length: usize) -> String { + if text.contains('\n') { + let text = text.split('\n').next().unwrap(); + + format!("{}...", &text[..text.len().min(length) - 3]) + } else if text.len() > length { + format!("{}...", &text[..length - 3]) } 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.url, - shorten_text(&commit.message, 50), + shorten_single_line_text(&commit.message, 50), commit.author.name ) }) diff --git a/crates/services/autumn/Cargo.toml b/crates/services/autumn/Cargo.toml index 14984973..ec00b275 100644 --- a/crates/services/autumn/Cargo.toml +++ b/crates/services/autumn/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-autumn" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false @@ -17,6 +17,7 @@ jxl-oxide = "0.8.1" kamadak-exif = "0.5.4" # revolt_little_exif = "0.5.1" image = { version = "0.25.2" } # avif encode requires dav1d system library: features = ["avif-native"] +thumbhash = "0.1.0" # File processing revolt_clamav-client = { version = "0.1.5" } @@ -40,18 +41,16 @@ serde = { version = "1.0", features = ["derive"] } tokio = { version = "1.0", features = ["full"] } # Core crates -revolt-files = { version = "0.9.4", path = "../../core/files" } -revolt-config = { version = "0.9.4", path = "../../core/config" } -revolt-database = { version = "0.9.4", path = "../../core/database", features = [ +revolt-files = { version = "0.12.0", path = "../../core/files" } +revolt-config = { version = "0.12.0", path = "../../core/config" } +revolt-database = { version = "0.12.0", path = "../../core/database", features = [ "axum-impl", ] } -revolt-result = { version = "0.9.4", path = "../../core/result", features = [ +revolt-result = { version = "0.12.0", path = "../../core/result", features = [ "utoipa", "axum", ] } -revolt-ratelimits = { version = "0.9.4", path = "../../core/ratelimits", features = [ - "axum", -] } +revolt-ratelimits = { version = "0.12.0", path = "../../core/ratelimits", features = ["axum"] } # Axum / web server tempfile = "3.12.0" diff --git a/crates/services/autumn/src/api.rs b/crates/services/autumn/src/api.rs index 65bfce2d..16ba8153 100644 --- a/crates/services/autumn/src/api.rs +++ b/crates/services/autumn/src/api.rs @@ -345,7 +345,7 @@ async fn fetch_preview( 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, .. }); // Process GIFs if avatar or icon if !matches!(hash.metadata, Metadata::Image { .. }) diff --git a/crates/services/autumn/src/exif.rs b/crates/services/autumn/src/exif.rs index f87c679b..3cb5eed5 100644 --- a/crates/services/autumn/src/exif.rs +++ b/crates/services/autumn/src/exif.rs @@ -15,7 +15,12 @@ pub async fn strip_metadata( mime: &str, ) -> Result<(Vec, 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 // "image/jpeg" | "image/png" => { // // use little_exif to strip metadata except for orientation and colour profile @@ -85,7 +90,15 @@ pub async fn strip_metadata( _ => (*width, *height), }; - Ok((bytes, Metadata::Image { width, height })) + Ok(( + bytes, + Metadata::Image { + width, + height, + thumbhash: thumbhash.clone(), + animated: *animated, + }, + )) } // TODO: JXLs store EXIF data but we don't have the ability to write them "image/jxl" => Ok((buf, metadata)), diff --git a/crates/services/autumn/src/metadata.rs b/crates/services/autumn/src/metadata.rs index 0375f5f9..729cd169 100644 --- a/crates/services/autumn/src/metadata.rs +++ b/crates/services/autumn/src/metadata.rs @@ -1,7 +1,8 @@ use std::io::Cursor; +use image::{GenericImageView, ImageError, ImageReader}; use revolt_database::Metadata; -use revolt_files::{image_size, video_size}; +use revolt_files::{image_size, is_animated, video_size}; use tempfile::NamedTempFile; /// Intersection of what infer can detect and what image-rs supports @@ -25,6 +26,17 @@ pub fn generate_metadata(f: &NamedTempFile, mime_type: &str) -> Metadata { .map(|(width, height)| Metadata::Image { width: width 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() } else if mime_type.starts_with("video/") { diff --git a/crates/services/gifbox/Cargo.toml b/crates/services/gifbox/Cargo.toml index 6588f8ef..5a4d8e21 100644 --- a/crates/services/gifbox/Cargo.toml +++ b/crates/services/gifbox/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-gifbox" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false @@ -17,19 +17,19 @@ tokio = { version = "1.0", features = ["full"] } reqwest = { version = "0.12", features = ["json"] } # Core crates -revolt-config = { version = "0.9.4", path = "../../core/config" } -revolt-models = { version = "0.9.4", path = "../../core/models" } -revolt-result = { version = "0.9.4", path = "../../core/result", features = [ +revolt-config = { version = "0.12.0", path = "../../core/config" } +revolt-models = { version = "0.12.0", path = "../../core/models" } +revolt-result = { version = "0.12.0", path = "../../core/result", features = [ "utoipa", "axum", ] } -revolt-coalesced = { version = "0.9.4", path = "../../core/coalesced", features = [ +revolt-coalesced = { version = "0.12.0", path = "../../core/coalesced", features = [ "queue", ] } -revolt-database = { version = "0.9.4", path = "../../core/database", features = [ +revolt-database = { version = "0.12.0", path = "../../core/database", features = [ "axum-impl", ] } -revolt-ratelimits = { version = "0.9.4", path = "../../core/ratelimits", features = [ +revolt-ratelimits = { version = "0.12.0", path = "../../core/ratelimits", features = [ "axum", ] } diff --git a/crates/services/january/Cargo.toml b/crates/services/january/Cargo.toml index acbba6fc..0595827b 100644 --- a/crates/services/january/Cargo.toml +++ b/crates/services/january/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "revolt-january" -version = "0.9.4" +version = "0.12.0" edition = "2021" license = "AGPL-3.0-or-later" publish = false @@ -33,13 +33,13 @@ tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Core crates -revolt-config = { version = "0.9.4", path = "../../core/config" } -revolt-models = { version = "0.9.4", path = "../../core/models" } -revolt-result = { version = "0.9.4", path = "../../core/result", features = [ +revolt-config = { version = "0.12.0", path = "../../core/config" } +revolt-models = { version = "0.12.0", path = "../../core/models" } +revolt-result = { version = "0.12.0", path = "../../core/result", features = [ "utoipa", "axum", ] } -revolt-files = { version = "0.9.4", path = "../../core/files" } +revolt-files = { version = "0.12.0", path = "../../core/files" } # Axum / web server axum = { version = "0.7.5" } diff --git a/docs/README.md b/docs/README.md index b28211a9..a31d8713 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,13 +5,13 @@ This website is built using [Docusaurus](https://docusaurus.io/), a modern stati ## Installation ```bash -yarn +pnpm install ``` ## Local Development ```bash -yarn start +pnpm start ``` This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. @@ -19,7 +19,7 @@ This command starts a local development server and opens up a browser window. Mo ## Build ```bash -yarn build +pnpm build ``` This command generates static content into the `build` directory and can be served using any static contents hosting service. @@ -29,13 +29,13 @@ This command generates static content into the `build` directory and can be serv Using SSH: ```bash -USE_SSH=true yarn deploy +USE_SSH=true pnpm run deploy ``` Not using SSH: ```bash -GIT_USER= yarn deploy +GIT_USER= pnpm run deploy ``` If you are using GitHub pages for hosting, this command is a convenient way to build the website and push to the `gh-pages` branch. diff --git a/docs/docs/developers/endpoints.md b/docs/docs/developers/endpoints.md index b22c64e2..a89b4d3e 100644 --- a/docs/docs/developers/endpoints.md +++ b/docs/docs/developers/endpoints.md @@ -8,16 +8,12 @@ sidebar_position: 1 You can connect to the API on the following URLs: -| URL | Release | Description | -| ----------------------------- | :--------: | --------------------------- | -| `https://api.revolt.chat` | Production | Primary API endpoint | -| `https://app.revolt.chat/api` | Production | API endpoint for old client | -| `https://revolt.chat/api` | Staging | API endpoint for new client | +| URL | Release | Description | +| ----------------------------- | :--------: | ---------------------------------- | +| `https://api.stoat.chat` | Production | Primary API endpoint | You can connect to the events server on the following URLs: -| URL | Release | Description | -| ------------------------------ | :--------: | ------------------------------ | -| `wss://ws.revolt.chat` | Production | Primary events endpoint | -| `wss://app.revolt.chat/events` | Production | Events endpoint for old client | -| `wss://revolt.chat/events` | Staging | Events endpoint for new client | +| URL | Release | Description | +| ------------------------------ | :--------: | ------------------------------------- | +| `wss://events.stoat.chat` | Production | Primary events endpoint | diff --git a/docs/docs/developers/legacy-plugin-api.md b/docs/docs/developers/legacy-plugin-api.md index ac5c58fc..d2c452c8 100644 --- a/docs/docs/developers/legacy-plugin-api.md +++ b/docs/docs/developers/legacy-plugin-api.md @@ -10,7 +10,7 @@ The Plugin API is very powerful. **Tread carefully.** **Zero guarantees or sandboxes are provided.** Your code is run as-is. ::: -This document details the very experimental plugin API available in [Revite](https://github.com/revoltchat/revite). +This document details the very experimental plugin API available in [Revite](https://github.com/stoatchat/for-legacy-web). This is more or less a proof of concept but can be used to achieve some simple client modifications. diff --git a/docs/docs/developing/backend/new_features.md b/docs/docs/developing/backend/new_features.md index c9517180..18d43afc 100644 --- a/docs/docs/developing/backend/new_features.md +++ b/docs/docs/developing/backend/new_features.md @@ -4,12 +4,12 @@ New API features must be documented where appropriate, this document aims to cov Before writing new API features, generally a good idea to: -- Consult with other developers in the [Revolt Developers space](https://rvlt.gg/API) +- Consult with other developers in the [Stoat Developers space](https://stt.gg/API) - If it's a relatively big feature, also [write an RFC](https://github.com/revoltchat/rfcs) When your feature is ready to release, ensure to: - Update backend documentation (what you're reading now!) if applicable -- Update the [developers documentation](https://github.com/revoltchat/wiki) if applicable +- Update the [developers documentation](https://github.com/stoatchat/stoatchat/tree/main/docs) if applicable - Update the Feature Matrix (or ask someone that can to do so) - Ensure it is properly listed in the backend release changelog diff --git a/docs/docs/developing/contrib.md b/docs/docs/developing/contrib.md index 39520b51..565f92ea 100644 --- a/docs/docs/developing/contrib.md +++ b/docs/docs/developing/contrib.md @@ -12,14 +12,18 @@ This is the contribution guide for developers wanting to help out with Stoat. - Sign-off your commits ([Git flag](https://git-scm.com/docs/git-commit#Documentation/git-commit.txt---signoff)), [read here about DCO obligations](https://developercertificate.org/). - Sign commits where possible, [learn more about that here](https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits). -- Prefer to use [Conventional Commit style](https://www.conventionalcommits.org/en/v1.0.0-beta.2/). +- Prefer using [Conventional Commit style](https://www.conventionalcommits.org/en/v1.0.0-beta.2/). - If present, e.g. `prettier`, `cargo fmt`, use the formatter. - Try to keep each PR bound to a single feature or change, multiple bug fixes may be fine in some cases. This is to avoid your PR getting stuck due to parts of it having conflicts or other issues. ### Merging Pull Requests -All PR titles must use use [Conventional Commit style](https://www.conventionalcommits.org/en/v1.0.0-beta.2/) and will be squash merged! +All PR titles must use use [Conventional Commit style](https://www.conventionalcommits.org/en/v1.0.0-beta.2/) and will be squash merged. + +### Use of generative AI + +Please do not open PRs generated with LLMs. ## What can I help with? diff --git a/docs/docs/help.md b/docs/docs/help.md index ae95dbf7..91887e6b 100644 --- a/docs/docs/help.md +++ b/docs/docs/help.md @@ -13,7 +13,7 @@ We want to hear what you think and appreciate and await your feature suggestions Typically, you can open issues on the relevant [GitHub repositories](https://github.com/stoatchat). - + ### 2. Translate diff --git a/git-town.toml b/git-town.toml new file mode 100644 index 00000000..65a40966 --- /dev/null +++ b/git-town.toml @@ -0,0 +1,8 @@ +# See https://www.git-town.com/configuration-file for details + +[branches] +main = "main" + +[hosting] +forge-type = "github" +github-connector = "gh" diff --git a/release-please-config.json b/release-please-config.json index bac63361..14710a30 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -205,6 +205,11 @@ "path": "crates/daemons/pushd/Cargo.toml", "jsonpath": "$.dependencies['revolt-presence'].version" }, + { + "type": "toml", + "path": "crates/daemons/pushd/Cargo.toml", + "jsonpath": "$.dependencies['revolt-parser'].version" + }, { "type": "toml", "path": "crates/daemons/voice-ingress/Cargo.toml", diff --git a/version.txt b/version.txt index a602fc9e..ac454c6a 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.9.4 +0.12.0