Compare commits

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

No files matched your search

-24
View File
@@ -1,24 +0,0 @@
<!-- Describe your changes -->
Fixes # (issue)
## How was this PR tested?
<!-- What did you do to test your changes? -->
- [ ] Test A
- [ ] Test B
## Checklist:
- [ ] I have carefully read [the contributing guidelines](https://developers.stoat.chat/developing/contrib/)
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation if applicable
- [ ] I have no unrelated changes in the PR
- [ ] I have confirmed that any new dependencies are strictly necessary
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
## Please declare, if any, LLM usage involved in creating this PR
...
-46
View File
@@ -1,46 +0,0 @@
name: Docker PR Image Cleanup
on:
pull_request:
types:
- closed
permissions:
contents: read
packages: write
concurrency:
group: docker-cleanup-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
cleanup:
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.head.repo.fork }}
strategy:
fail-fast: false
matrix:
package:
- base
- api
- events
- file-server
- proxy
- gifbox
- crond
- pushd
- voice-ingress
steps:
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORG: stoatchat
PACKAGE: ${{ matrix.package }}
TAG: pr-${{ github.event.pull_request.number }}
run: |
set -euo pipefail
gh api --paginate \
"/orgs/${ORG}/packages/container/${PACKAGE}/versions" \
--jq ".[] | select(.metadata.container.tags | index(\"${TAG}\")) | .id" \
| while read -r id; do
gh api -X DELETE "/orgs/${ORG}/packages/container/${PACKAGE}/versions/${id}"
done
+14 -23
View File
@@ -5,6 +5,8 @@ on:
tags: tags:
- "*" - "*"
pull_request: pull_request:
paths:
- "Dockerfile"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -17,9 +19,9 @@ concurrency:
jobs: jobs:
base: base:
name: Test base image build (fork) name: Test base image build
runs-on: arc-runner-set runs-on: arc-runner-set
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork if: github.event_name == 'pull_request'
steps: steps:
# Configure build environment # Configure build environment
- name: Checkout - name: Checkout
@@ -40,7 +42,7 @@ jobs:
publish: publish:
runs-on: arc-runner-set runs-on: arc-runner-set
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }} if: github.event_name != 'pull_request'
name: Publish Docker images name: Publish Docker images
steps: steps:
# Configure build environment # Configure build environment
@@ -57,15 +59,6 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine base image tag
id: base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "tag=pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
# Build the image # Build the image
- name: Build base image - name: Build base image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
@@ -73,9 +66,7 @@ jobs:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} tags: ghcr.io/${{ github.repository_owner }}/base:latest
cache-from: type=gha,scope=buildx-base-multi-arch
cache-to: type=gha,scope=buildx-base-multi-arch,mode=max
# stoatchat/api # stoatchat/api
- name: Docker meta - name: Docker meta
@@ -93,7 +84,7 @@ jobs:
file: crates/delta/Dockerfile file: crates/delta/Dockerfile
tags: ${{ steps.meta-delta.outputs.tags }} tags: ${{ steps.meta-delta.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-delta.outputs.labels }} labels: ${{ steps.meta-delta.outputs.labels }}
# stoatchat/events # stoatchat/events
@@ -112,7 +103,7 @@ jobs:
file: crates/bonfire/Dockerfile file: crates/bonfire/Dockerfile
tags: ${{ steps.meta-bonfire.outputs.tags }} tags: ${{ steps.meta-bonfire.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-bonfire.outputs.labels }} labels: ${{ steps.meta-bonfire.outputs.labels }}
# stoatchat/file-server # stoatchat/file-server
@@ -131,7 +122,7 @@ jobs:
file: crates/services/autumn/Dockerfile file: crates/services/autumn/Dockerfile
tags: ${{ steps.meta-autumn.outputs.tags }} tags: ${{ steps.meta-autumn.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-autumn.outputs.labels }} labels: ${{ steps.meta-autumn.outputs.labels }}
# stoatchat/proxy # stoatchat/proxy
@@ -150,7 +141,7 @@ jobs:
file: crates/services/january/Dockerfile file: crates/services/january/Dockerfile
tags: ${{ steps.meta-january.outputs.tags }} tags: ${{ steps.meta-january.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-january.outputs.labels }} labels: ${{ steps.meta-january.outputs.labels }}
# stoatchat/gifbox # stoatchat/gifbox
@@ -169,7 +160,7 @@ jobs:
file: crates/services/gifbox/Dockerfile file: crates/services/gifbox/Dockerfile
tags: ${{ steps.meta-gifbox.outputs.tags }} tags: ${{ steps.meta-gifbox.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-gifbox.outputs.labels }} labels: ${{ steps.meta-gifbox.outputs.labels }}
# stoatchat/crond # stoatchat/crond
@@ -188,7 +179,7 @@ jobs:
file: crates/daemons/crond/Dockerfile file: crates/daemons/crond/Dockerfile
tags: ${{ steps.meta-crond.outputs.tags }} tags: ${{ steps.meta-crond.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-crond.outputs.labels }} labels: ${{ steps.meta-crond.outputs.labels }}
# stoatchat/pushd # stoatchat/pushd
@@ -207,7 +198,7 @@ jobs:
file: crates/daemons/pushd/Dockerfile file: crates/daemons/pushd/Dockerfile
tags: ${{ steps.meta-pushd.outputs.tags }} tags: ${{ steps.meta-pushd.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-pushd.outputs.labels }} labels: ${{ steps.meta-pushd.outputs.labels }}
# stoatchat/voice-ingress # stoatchat/voice-ingress
@@ -226,5 +217,5 @@ jobs:
file: crates/daemons/voice-ingress/Dockerfile file: crates/daemons/voice-ingress/Dockerfile
tags: ${{ steps.meta-voice-ingress.outputs.tags }} tags: ${{ steps.meta-voice-ingress.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }} BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-voice-ingress.outputs.labels }} labels: ${{ steps.meta-voice-ingress.outputs.labels }}
-2
View File
@@ -21,6 +21,4 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish - name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: mise publish --workspace run: mise publish --workspace
-2
View File
@@ -37,7 +37,6 @@ jobs:
- name: Reference Test - name: Reference Test
env: env:
TEST_DB: REFERENCE TEST_DB: REFERENCE
continue-on-error: ${{ github.ref_name == 'main' }}
run: | run: |
mise test mise test
@@ -45,7 +44,6 @@ jobs:
env: env:
TEST_DB: MONGODB TEST_DB: MONGODB
MONGODB: mongodb://localhost MONGODB: mongodb://localhost
continue-on-error: ${{ github.ref_name == 'main' }}
run: | run: |
mise test mise test
-9
View File
@@ -16,13 +16,4 @@ idiomatic_version_file_enable_tools = ["rust"]
[tasks.start] [tasks.start]
description = "Run all services" description = "Run all services"
depends = ["docker:start", "build"] depends = ["docker:start", "build"]
wait_for = ["docker:start", "build"]
run = [{ task = "service:*" }] run = [{ task = "service:*" }]
[env]
BUILDER = "cargo"
DOCKER_NETWORK_NAME = "stoatchat_default"
DATABASE_PORT = "27017"
RABBIT_PORT = "5672"
REDIS_PORT = "6379"
_.file = { path = ".env", tools = true }
+1 -1
View File
@@ -2,4 +2,4 @@
#MISE description="Build project" #MISE description="Build project"
set -e set -e
${BUILDER} build "$@" cargo build "$@"
+1 -9
View File
@@ -2,12 +2,4 @@
#MISE description="Start Docker containers" #MISE description="Start Docker containers"
set -e set -e
docker compose up -d docker compose up -d "$@"
docker run \
--network=${DOCKER_NETWORK_NAME} \
--name wait \
--rm dokku/wait -c \
rabbit:${RABBIT_PORT},\
database:${DATABASE_PORT},\
redis:${REDIS_PORT}
+1 -1
View File
@@ -2,4 +2,4 @@
#MISE description="Stop Docker containers" #MISE description="Stop Docker containers"
set -e set -e
docker compose down docker compose down "$@"
+1 -1
View File
@@ -1,3 +1,3 @@
{ {
".": "0.13.7" ".": "0.12.0"
} }
-102
View File
@@ -1,107 +1,5 @@
# Changelog # Changelog
## [0.13.7](https://github.com/stoatchat/stoatchat/compare/v0.13.6...v0.13.7) (2026-05-21)
### Bug Fixes
* sanitize emoji input to handle variation selectors ([#774](https://github.com/stoatchat/stoatchat/issues/774)) ([2d308e0](https://github.com/stoatchat/stoatchat/commit/2d308e03d58c19f27b5b4d65dc2a15ef20b56190))
* update mention count badge for channel acks ([#769](https://github.com/stoatchat/stoatchat/issues/769)) ([0d9ae50](https://github.com/stoatchat/stoatchat/commit/0d9ae508d9d2199f0e408b8ca634d20489be6f61))
## [0.13.6](https://github.com/stoatchat/stoatchat/compare/v0.13.5...v0.13.6) (2026-05-18)
### Features
* Update FCM payload for android notifications ([#766](https://github.com/stoatchat/stoatchat/issues/766)) ([acbc087](https://github.com/stoatchat/stoatchat/commit/acbc087982e9aeb05cabc5ab4c9b1291f67490ad))
* user slowmode events ([#760](https://github.com/stoatchat/stoatchat/issues/760)) ([af0d8aa](https://github.com/stoatchat/stoatchat/commit/af0d8aad14dc68d88159d0e1c714077d362e21e4))
### Bug Fixes
* include `minio` region as tests need it ([#761](https://github.com/stoatchat/stoatchat/issues/761)) ([298742d](https://github.com/stoatchat/stoatchat/commit/298742dbad4eafae356f976c56b9db23904b0c3a))
* set env var for publishing crates ([#768](https://github.com/stoatchat/stoatchat/issues/768)) ([018afaf](https://github.com/stoatchat/stoatchat/commit/018afaf38f6330d92dad2a68b640c0cb3f6b639a))
* Use proper headers to determine IP when not behind cloudflare ([#764](https://github.com/stoatchat/stoatchat/issues/764)) ([494c8b7](https://github.com/stoatchat/stoatchat/commit/494c8b7cabaae2a51039a7a5b559d5e2e5279554))
* voice ingress crashing due to new Result in AMQP::new_auto() ([#765](https://github.com/stoatchat/stoatchat/issues/765)) ([2871632](https://github.com/stoatchat/stoatchat/commit/2871632382395cb20cbe0047c542d3ac31ff3f03))
### Miscellaneous Chores
* switch to lapin ([#767](https://github.com/stoatchat/stoatchat/issues/767)) ([5b19853](https://github.com/stoatchat/stoatchat/commit/5b1985381ae829a92c80a19e91a414cd9dc4de93))
## [0.13.5](https://github.com/stoatchat/stoatchat/compare/v0.13.4...v0.13.5) (2026-05-17)
### Bug Fixes
* dont panic on hash missing when deleting files ([#755](https://github.com/stoatchat/stoatchat/issues/755)) ([c902077](https://github.com/stoatchat/stoatchat/commit/c902077cf51076fee11712eb732dc8a8f786fc4b))
## [0.13.4](https://github.com/stoatchat/stoatchat/compare/v0.13.3...v0.13.4) (2026-05-16)
### Bug Fixes
* add TLS feature to livekit-api crate ([#753](https://github.com/stoatchat/stoatchat/issues/753)) ([6cfee1f](https://github.com/stoatchat/stoatchat/commit/6cfee1f601c1e084df7c8f1e7a5e8a560d1dd514))
## [0.13.3](https://github.com/stoatchat/stoatchat/compare/v0.13.2...v0.13.3) (2026-05-15)
### Bug Fixes
* don't automatically set up rabbitmq in delta ([#749](https://github.com/stoatchat/stoatchat/issues/749)) ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
* don't declare queues which seem to cause the backend to crash in prod ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
## [0.13.2](https://github.com/stoatchat/stoatchat/compare/v0.13.1...v0.13.2) (2026-05-11)
### Bug Fixes
* update default exchange to `revolt.default` ([#746](https://github.com/stoatchat/stoatchat/issues/746)) ([fcb8091](https://github.com/stoatchat/stoatchat/commit/fcb8091cd7a00d7f26c798daa33aae4b923b2a8b))
## [0.13.1](https://github.com/stoatchat/stoatchat/compare/v0.13.0...v0.13.1) (2026-05-10)
### Bug Fixes
* amqprs startup bug ([#744](https://github.com/stoatchat/stoatchat/issues/744)) ([1100eaf](https://github.com/stoatchat/stoatchat/commit/1100eaf46f849f2509ae01ac497556ca33bde778))
## [0.13.0](https://github.com/stoatchat/stoatchat/compare/v0.12.1...v0.13.0) (2026-05-08)
### Features
* add embed support for YouTube Shorts ([#734](https://github.com/stoatchat/stoatchat/issues/734)) ([d46c7f7](https://github.com/stoatchat/stoatchat/commit/d46c7f7f3c04524c0639c3e0a122626f8e0b3bf7))
* add emoji rename endpoint ([#714](https://github.com/stoatchat/stoatchat/issues/714)) ([23ad135](https://github.com/stoatchat/stoatchat/commit/23ad1359834bb7d07a460b8678d6a6ebffc73eb0))
* add legal links to root payload ([#733](https://github.com/stoatchat/stoatchat/issues/733)) ([21d8201](https://github.com/stoatchat/stoatchat/commit/21d82018cf84ab0fdd10613d254b9562aea8eea3))
* add role icon support ([#724](https://github.com/stoatchat/stoatchat/issues/724)) ([841985d](https://github.com/stoatchat/stoatchat/commit/841985d3b994df1c6eefab2fc7ecbd77ab22c493))
* Add webhook endpoints for editing and deleting messages ([#682](https://github.com/stoatchat/stoatchat/issues/682)) ([6f3441c](https://github.com/stoatchat/stoatchat/commit/6f3441cf4acac2a8e6e1bf07a279a153b80f7956))
* automatically sanitise usernames on create/update ([#689](https://github.com/stoatchat/stoatchat/issues/689)) ([e937697](https://github.com/stoatchat/stoatchat/commit/e93769786c7669485a659ee471630740d3cea702))
* blacklist private ip ranges and add january domain blocklist ([#731](https://github.com/stoatchat/stoatchat/issues/731)) ([6b41db9](https://github.com/stoatchat/stoatchat/commit/6b41db984bb491b2e58324309cc70d8c14e0b814))
* Rewrite acks ([#741](https://github.com/stoatchat/stoatchat/issues/741)) ([ab5bd47](https://github.com/stoatchat/stoatchat/commit/ab5bd47a39ee889de0b5ae6e7b560620853daead))
### Bug Fixes
* add new_user_hours to configuration limits ([#729](https://github.com/stoatchat/stoatchat/issues/729)) ([279f5d5](https://github.com/stoatchat/stoatchat/commit/279f5d5fd7af2df55902c706859ec07f569cdb1e))
* add reconnection policy to Redis subscriber to prevent ghost state ([#708](https://github.com/stoatchat/stoatchat/issues/708)) ([057f2bb](https://github.com/stoatchat/stoatchat/commit/057f2bb8b359f8b942741a30ff54eeb8fbe3e0b1))
* docker compose file had personal url in it ([#742](https://github.com/stoatchat/stoatchat/issues/742)) ([0719985](https://github.com/stoatchat/stoatchat/commit/0719985ac5636590f91e6f9ec4b68f3eded70c13))
* don't strip ICC from exif ([#735](https://github.com/stoatchat/stoatchat/issues/735)) ([d76a711](https://github.com/stoatchat/stoatchat/commit/d76a71141f3e508f6308ba52fa28eaeb56fb3438))
* dont send notification in fcm ([#721](https://github.com/stoatchat/stoatchat/issues/721)) ([89171e9](https://github.com/stoatchat/stoatchat/commit/89171e9bd0f15711157e78c6eec0fe7b480de93a))
* encode filenames in redirects ([#737](https://github.com/stoatchat/stoatchat/issues/737)) ([9fd7128](https://github.com/stoatchat/stoatchat/commit/9fd7128f800badbd184baf943d4f799e601201e4))
* january ip redirects & domain resolver ([#738](https://github.com/stoatchat/stoatchat/issues/738)) ([356491e](https://github.com/stoatchat/stoatchat/commit/356491e934b274f9e895df883dd63ef0b3123510))
* update message length validation to remove upper limit ([#723](https://github.com/stoatchat/stoatchat/issues/723)) ([ed4fd5e](https://github.com/stoatchat/stoatchat/commit/ed4fd5ebfe6d0ea534a0898da4afdc1f4e2cd6c5))
* use correct response for NoEffect errors ([#732](https://github.com/stoatchat/stoatchat/issues/732)) ([5378cd2](https://github.com/stoatchat/stoatchat/commit/5378cd22b4c7d85f44c31a6af0dda00941b80d5c))
## [0.12.1](https://github.com/stoatchat/stoatchat/compare/v0.12.0...v0.12.1) (2026-04-10)
### Bug Fixes
* add migration to update existing files to be animated ([#705](https://github.com/stoatchat/stoatchat/issues/705)) ([f2c056a](https://github.com/stoatchat/stoatchat/commit/f2c056a1515be493b195f3f5db5886c2ddf36700))
* don't send self dm notifications ([#706](https://github.com/stoatchat/stoatchat/issues/706)) ([f30b729](https://github.com/stoatchat/stoatchat/commit/f30b729ca90d0be6853c57ab4935694e5e59ae56))
* mise start + missing docker image ([#564](https://github.com/stoatchat/stoatchat/issues/564)) ([fb8fe16](https://github.com/stoatchat/stoatchat/commit/fb8fe1655776791421284a6a093e86f0320c258a))
* test failure due to wrong assertion ([#707](https://github.com/stoatchat/stoatchat/issues/707)) ([f81e329](https://github.com/stoatchat/stoatchat/commit/f81e3291bdd57af9ceedb2987b111acc7051d69c))
## [0.12.0](https://github.com/stoatchat/stoatchat/compare/v0.11.5...v0.12.0) (2026-03-28) ## [0.12.0](https://github.com/stoatchat/stoatchat/compare/v0.11.5...v0.12.0) (2026-03-28)
Generated
+2026 -2879
View File
File diff suppressed because it is too large. Load diff
+9 -162
View File
@@ -1,5 +1,5 @@
[workspace] [workspace]
resolver = "3" resolver = "2"
members = [ members = [
"crates/delta", "crates/delta",
@@ -20,130 +20,24 @@ lto = true
[workspace.dependencies] [workspace.dependencies]
# Async # Async
async-trait = "0.1.89" async-trait = "0.1.89"
tokio = "1.49.0" tokio = { version = "1.49.0", features = ["macros", "rt"] }
async-channel = "2.3.1"
futures = "0.3.32"
async-std = "1.8.0"
async-tungstenite = "0.17.0"
futures-locks = "0.7.1"
async-lock = "2.8.0"
async-recursion = "1.0.4"
# Error Handling # Error Handling
anyhow = "1.0.100" anyhow = "1.0.100"
thiserror = "2.0.18" thiserror = "2.0.18"
sentry = "0.31.5"
sentry-anyhow = "0.38.1"
# Data Validation # Other Utilities
regex = "1.12.3" uuid = { version = "1.19.0", features = ["v4"] }
validator = "0.16"
# Data Types
uuid = "1.19.0"
ulid = "1.2.1"
nanoid = "0.4.0"
typenum = "1.17.0"
num_enum = "0.6.1"
bitfield = "0.13.2"
# Time
chrono = "0.4.15"
iso8601-timestamp = "0.2.10"
# Data Collections
lru = "0.16.3"
indexmap = "2.13.1"
dashmap = "5.2.0"
moka = "0.12.8"
lru_time_cache = "0.11.11"
deadqueue = "0.2.4"
# Web scraping
scraper = "0.20.0"
encoding_rs = "0.8.34"
# Mail
lettre = "0.10.0-alpha.4"
# HTTP Requests
reqwest = "0.13.2"
isahc = "1.7"
# Notifications
fcm_v1 = "0.3.0"
web-push = "0.10.0"
revolt_a2 = "0.10"
# Parsing
logos = "0.15"
# SVG rendering
usvg = "0.44.0"
resvg = "0.44.0"
tiny-skia = "0.11.4"
# Logging
log = "0.4.29"
pretty_env_logger = "0.4.0"
# Redis
redis-kiss = "0.1.4"
fred = "8.0.1"
# Serialisation
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = { version = "1", features = ["derive"] }
strum_macros = "0.26.4"
# MongoDB
bson = { version = "2.1.0" }
mongodb = { version = "3.1.0" }
# S3
aws-config = "1.5.5"
aws-sdk-s3 = "1.46.0"
# Axum (HTTP server) # Axum (HTTP server)
axum-macros = "0.4.1" axum-macros = "0.4.1"
axum_typed_multipart = "0.12.1" axum_typed_multipart = "0.12.1"
axum = "0.7.5" axum = { version = "0.7.5", features = ["multipart"] }
axum-extra = "0.9" tower-http = { version = "0.5.2", features = ["cors", "trace"] }
tower-http = "0.5.2"
# Rocket (HTTP server)
rocket = "0.5.1"
rocket_empty = "0.1.1"
revolt_rocket_okapi = "0.10.0"
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_authifier = "1.0.16"
rocket_prometheus = "0.10.0-rc.3"
# Spec Generation
utoipa = "4.2.3"
revolt_okapi = "0.9.1"
schemars = "0.8.8"
utoipa-scalar = "0.1.0"
# Image Processing # Image Processing
jxl-oxide = "0.12.5" jxl-oxide = { version = "0.12.5", features = ["image"] }
sha2 = "0.10.8" image = "0.25.9"
kamadak-exif = "0.5.4"
webp = "0.3.0"
image = "0.25.2" # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = "0.1.0"
lcms2 = "6.1.1" # for color profile processing
# File processing
revolt_clamav-client = "0.1.5"
simdutf8 = "0.1.4"
# Content type processing
infer = "0.16.0"
ffprobe = "0.4.0"
imagesize = "0.13.0"
# OpenTelemetry # OpenTelemetry
tracing = "0.1.44" tracing = "0.1.44"
@@ -153,51 +47,4 @@ tracing-subscriber = { version = "0.3.22", features = [
opentelemetry = { version = "0.31.0", features = ["logs"] } opentelemetry = { version = "0.31.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.31.0", features = ["logs"] } opentelemetry_sdk = { version = "0.31.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.31.0", features = ["logs"] } opentelemetry-otlp = { version = "0.31.0", features = ["logs"] }
opentelemetry-appender-tracing = "0.31.1" opentelemetry-appender-tracing = { version = "0.31.1" }
# Authifier
authifier = "1.0.16"
# RabbitMQ
lapin = "4.7.1"
# Voice
livekit-api = "0.4.4"
livekit-protocol = "0.7.4"
livekit-runtime = "0.4.0"
# Other Utilities
once_cell = "1.9.0"
config = "0.13.3"
cached = "0.44.0"
rand = "0.8.5"
base64 = "0.21.3"
decancer = "3.3.3"
linkify = "0.8.1"
url-escape = "0.1.1"
revolt_optional_struct = "0.2.0"
unicode-segmentation = "1.10.1"
querystring = "1.1.0"
tempfile = "3.12.0"
aes-gcm = "0.10.3"
auto_ops = "0.3.0"
url = "2.2.2"
impl_ops = "0.1.1"
lazy_static = "1.5.0"
mime = "0.3.17"
futures-lite = "2.6.1"
# Build Dependencies
vergen = "7.5.0"
# Local packages
revolt-coalesced = { version = "0.13.7", path = "crates/core/coalesced" }
revolt-config = { version = "0.13.7", path = "crates/core/config" }
revolt-database = { version = "0.13.7", path = "crates/core/database" }
revolt-files = { version = "0.13.7", path = "crates/core/files" }
revolt-models = { version = "0.13.7", path = "crates/core/models" }
revolt-parser = { version = "0.13.7", path = "crates/core/parser" }
revolt-permissions = { version = "0.13.7", path = "crates/core/permissions" }
revolt-presence = { version = "0.13.7", path = "crates/core/presence" }
revolt-ratelimits = { version = "0.13.7", path = "crates/core/ratelimits" }
revolt-result = { version = "0.13.7", path = "crates/core/result" }
+33 -25
View File
@@ -76,14 +76,6 @@ mise install
mise build mise build
``` ```
> [!TIP]
> You can override `BUILDER` in your `.env` file to run cargo with mold if you installed it:
>
> ```bash
> # .env
> BUILDER = "mold --run cargo"
> ```
A default configuration `Revolt.toml` is present in this project that is suited for development. A default configuration `Revolt.toml` is present in this project that is suited for development.
If you'd like to change anything, create a `Revolt.overrides.toml` file and specify relevant variables. If you'd like to change anything, create a `Revolt.overrides.toml` file and specify relevant variables.
@@ -118,9 +110,21 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> ports: !override > ports: !override
> - "14072:5672" > - "14072:5672"
> - "14672:15672" > - "14672:15672"
>
> victoria-metrics:
> ports: !override
> - "18428:8428"
>
> victoria-logs:
> ports: !override
> - "19428:9428"
>
> victoria-traces:
> ports: !override
> - "14428:10428"
> ``` > ```
> >
> With the corresponding Revolt configuration: > And corresponding Revolt configuration:
> >
> ```toml > ```toml
> # Revolt.overrides.toml > # Revolt.overrides.toml
@@ -132,25 +136,32 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> [rabbit] > [rabbit]
> port = 14072 > port = 14072
> ``` > ```
>
> And mise configuration
>
> ```bash
> #.env
> DATABASE_PORT = "14017"
> RABBIT_PORT = "14072"
> REDIS_PORT = "14079"
> ```
Then continue: Then continue:
```bash ```bash
cp livekit.example.yml livekit.yml # start other necessary services
docker compose up -d
mise start # run the API server
cargo run --bin revolt-delta
# run the events server
cargo run --bin revolt-bonfire
# run the file server
cargo run --bin revolt-autumn
# run the proxy server
cargo run --bin revolt-january
# run the tenor proxy
cargo run --bin revolt-gifbox
# run the push daemon (not usually needed in regular development)
cargo run --bin revolt-pushd
# hint:
# mold -run <cargo build, cargo run, etc...>
# mold -run ./scripts/start.sh
``` ```
You can start a web client by doing the following in another terminal: You can start a web client by doing the following:
```bash ```bash
# if you do not have yarn yet and have a modern Node.js: # if you do not have yarn yet and have a modern Node.js:
@@ -164,9 +175,6 @@ cd stoat-web
When signing up, go to http://localhost:14080 to find confirmation/password reset emails. When signing up, go to http://localhost:14080 to find confirmation/password reset emails.
To stop all services, hit (CTRL + c) in the terminal you ran `mise start` and run `mise docker:stop`
## Deployment Guide ## Deployment Guide
### Cutting new crate releases ### Cutting new crate releases
@@ -202,7 +210,7 @@ If you have bumped the crate versions, proceed to [GitHub releases](https://gith
First, start the required services: First, start the required services:
```sh ```sh
docker compose up -d docker compose -f docker-compose.db.yml up -d
``` ```
Now run tests for whichever database: Now run tests for whichever database:
+24 -13
View File
@@ -1,3 +1,5 @@
name: stoat-backend
services: services:
# Redis # Redis
redis: redis:
@@ -8,20 +10,10 @@ services:
# MongoDB # MongoDB
database: database:
image: mongo image: mongo
command: mongod --replSet rs0
ports: ports:
- "27017:27017" - "27017:27017"
volumes: volumes:
- ./.data/db:/data/db - ./.data/db:/data/db
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
ulimits: ulimits:
nofile: nofile:
soft: 65536 soft: 65536
@@ -29,12 +21,11 @@ services:
# MinIO # MinIO
minio: minio:
image: firstfinger/minio:latest image: minio/minio
#command: server /data command: server /data
environment: environment:
MINIO_ROOT_USER: minioautumn MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn MINIO_ROOT_PASSWORD: minioautumn
MINIO_REGION: minio
volumes: volumes:
- ./.data/minio:/data - ./.data/minio:/data
ports: ports:
@@ -80,9 +71,29 @@ services:
MAILDEV_INCOMING_USER: smtp MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp MAILDEV_INCOMING_PASS: smtp
# LiveKit
livekit: livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.13 image: ghcr.io/stoatchat/livekit-server:v1.9.13
command: --config /etc/livekit.yml command: --config /etc/livekit.yml
network_mode: "host" network_mode: "host"
volumes: volumes:
- ./livekit.yml:/etc/livekit.yml - ./livekit.yml:/etc/livekit.yml
# VM
victoria-metrics:
image: victoriametrics/victoria-metrics:latest
ports:
- 8428:8428
# VL
victoria-logs:
image: docker.io/victoriametrics/victoria-logs:v1.43.1
ports:
- 9428:9428
command: -storageDataPath=victoria-logs-data
# VT
victoria-traces:
image: docker.io/victoriametrics/victoria-traces:latest
ports:
- 10428:10428
+30 -26
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-bonfire" name = "revolt-bonfire"
version = "0.13.7" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -9,38 +9,42 @@ publish = false
[dependencies] [dependencies]
# util # util
log = { workspace = true } log = "*"
sentry = { workspace = true } sentry = "0.31.5"
lru = { workspace = true } lru = "0.7.6"
ulid = { workspace = true } ulid = "0.5.0"
once_cell = { workspace = true } once_cell = "1.9.0"
redis-kiss = { workspace = true } redis-kiss = "0.1.4"
lru_time_cache = { workspace = true } lru_time_cache = "0.11.11"
async-channel = { workspace = true } async-channel = "2.3.1"
# parsing # parsing
querystring = { workspace = true } querystring = "1.1.0"
regex = { workspace = true } regex = "1.11.1"
# serde # serde
bincode = { workspace = true } bincode = "1.3.3"
serde_json = { workspace = true } serde_json = "1.0.79"
rmp-serde = { workspace = true } rmp-serde = "1.0.0"
serde = { workspace = true } serde = "1.0.136"
# async # async
futures = { workspace = true } futures = "0.3.21"
async-tungstenite = { workspace = true, features = ["async-std-runtime"] } async-tungstenite = { version = "0.17.0", features = ["async-std-runtime"] }
async-std = { workspace = true } async-std = { version = "1.8.0", features = [
"tokio1",
"tokio02",
"attributes",
] }
# core # core
authifier = { workspace = true } authifier = { version = "1.0.16" }
revolt-result = { workspace = true } revolt-result = { path = "../core/result" }
revolt-models = { workspace = true } revolt-models = { path = "../core/models" }
revolt-config = { workspace = true } revolt-config = { path = "../core/config" }
revolt-database = { workspace = true, features = ["voice"] } revolt-database = { path = "../core/database", features = ["voice"] }
revolt-permissions = { workspace = true } revolt-permissions = { path = "../core/permissions" }
revolt-presence = { workspace = true, features = ["redis-is-patched"] } revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
# redis # redis
fred = { workspace = true, features = ["subscriber-client"] } fred = { version = "8.0.1", features = ["subscriber-client"] }
-1
View File
@@ -1,7 +1,6 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use futures::future::join_all; use futures::future::join_all;
use redis_kiss::AsyncCommands;
use revolt_database::{ use revolt_database::{
events::client::{EventV1, ReadyPayloadFields}, events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery, util::permissions::DatabasePermissionQuery,
+4 -2
View File
@@ -1,5 +1,7 @@
use std::{ use std::{
collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, time::Duration collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
}; };
use async_std::sync::{Mutex, RwLock}; use async_std::sync::{Mutex, RwLock};
@@ -55,7 +57,7 @@ impl Default for Cache {
members: Default::default(), members: Default::default(),
servers: Default::default(), servers: Default::default(),
seen_events: LruCache::new(NonZeroUsize::new(20).unwrap()), seen_events: LruCache::new(20),
} }
} }
} }
+5 -58
View File
@@ -5,7 +5,7 @@ use authifier::AuthifierEvent;
use fred::{ use fred::{
error::RedisErrorKind, error::RedisErrorKind,
interfaces::{ClientLike, EventInterface, PubsubInterface}, interfaces::{ClientLike, EventInterface, PubsubInterface},
types::{ReconnectPolicy, RedisConfig}, types::RedisConfig,
}; };
use futures::{ use futures::{
channel::oneshot, channel::oneshot,
@@ -13,7 +13,7 @@ use futures::{
stream::{SplitSink, SplitStream}, stream::{SplitSink, SplitStream},
FutureExt, SinkExt, StreamExt, TryStreamExt, FutureExt, SinkExt, StreamExt, TryStreamExt,
}; };
use redis_kiss::{get_connection, AsyncCommands, PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI}; use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_config::report_internal_error; use revolt_config::report_internal_error;
use revolt_database::{ use revolt_database::{
events::{client::EventV1, server::ClientMessage}, events::{client::EventV1, server::ClientMessage},
@@ -32,7 +32,6 @@ use sentry::Level;
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback}; use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange}; use crate::events::state::{State, SubscriptionStateChange};
use revolt_models::v0;
type WsReader = SplitStream<WebSocketStream<TcpStream>>; type WsReader = SplitStream<WebSocketStream<TcpStream>>;
type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>; type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>;
@@ -129,14 +128,6 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
return; return;
} }
let slowmodes = fetch_user_slowmodes(&user_id).await.unwrap_or_default();
if !slowmodes.is_empty() {
let event = EventV1::UserSlowmodes { slowmodes };
if report_internal_error!(write.send(config.encode(&event)).await).is_err() {
return;
}
}
// Create presence session. // Create presence session.
let (first_session, session_id) = create_session(&user_id, 0).await; let (first_session, session_id) = create_session(&user_id, 0).await;
@@ -234,9 +225,9 @@ async fn listener(
.unwrap_or(REDIS_URI.to_string()); .unwrap_or(REDIS_URI.to_string());
let redis_config = RedisConfig::from_url(&url).unwrap(); let redis_config = RedisConfig::from_url(&url).unwrap();
let mut builder = fred::types::Builder::from_config(redis_config); let subscriber = match report_internal_error!(
builder.set_policy(ReconnectPolicy::new_exponential(8, 100, 30_000, 2)); fred::types::Builder::from_config(redis_config).build_subscriber_client()
let subscriber = match report_internal_error!(builder.build_subscriber_client()) { ) {
Ok(subscriber) => subscriber, Ok(subscriber) => subscriber,
Err(_) => return, Err(_) => return,
}; };
@@ -245,21 +236,16 @@ async fn listener(
return; return;
} }
// Let Fred automatically re-subscribe to tracked channels on reconnect.
subscriber.manage_subscriptions();
// Handle Redis connection dropping // Handle Redis connection dropping
let (clean_up_s, clean_up_r) = async_channel::bounded(1); let (clean_up_s, clean_up_r) = async_channel::bounded(1);
let clean_up_s = Arc::new(Mutex::new(clean_up_s)); let clean_up_s = Arc::new(Mutex::new(clean_up_s));
subscriber.on_error(move |err| { subscriber.on_error(move |err| {
warn!("Redis subscriber error: {:?}", err);
if let RedisErrorKind::Canceled = err.kind() { if let RedisErrorKind::Canceled = err.kind() {
let clean_up_s = clean_up_s.clone(); let clean_up_s = clean_up_s.clone();
spawn(async move { spawn(async move {
clean_up_s.lock().await.send(()).await.ok(); clean_up_s.lock().await.send(()).await.ok();
}); });
} }
// Transient errors (IO, timeout) are handled by the reconnect policy.
Ok(()) Ok(())
}); });
@@ -537,42 +523,3 @@ async fn worker(
} }
} }
} }
async fn fetch_user_slowmodes(user_id: &str) -> Option<Vec<v0::ChannelSlowmode>> {
let mut conn = get_connection().await.ok()?.into_inner();
let idx_key = format!("slowmode_idx:{}", user_id);
let channel_ids: Vec<String> = conn.smembers(&idx_key).await.unwrap_or_default();
if channel_ids.is_empty() {
return Some(vec![]);
}
// Bulk fetch all TTLs in one round trip
let mut pipe = redis_kiss::redis::pipe();
for channel_id in &channel_ids {
pipe.ttl(format!("slowmode:{}:{}", user_id, channel_id));
}
let ttls: Vec<i64> = pipe.query_async(&mut conn).await.unwrap_or_default();
// Partition into alive/expired in one pass
let mut slowmodes = vec![];
let mut expired = vec![];
for (channel_id, ttl) in channel_ids.iter().zip(ttls.iter()) {
if *ttl > 0 {
slowmodes.push(v0::ChannelSlowmode {
channel_id: channel_id.clone(),
duration: *ttl as u64,
retry_after: *ttl as u64,
});
} else {
expired.push(channel_id.as_str());
}
}
// Bulk remove all expired members in one SREM call
if !expired.is_empty() {
conn.srem::<_, _, ()>(&idx_key, expired).await.ok();
}
Some(slowmodes)
}
+5 -5
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-coalesced" name = "revolt-coalesced"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"] authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"]
@@ -15,12 +15,12 @@ cache = ["dep:lru"]
default = ["tokio"] default = ["tokio"]
[dependencies] [dependencies]
tokio = { workspace = true, features = ["sync"], optional = true } tokio = { version = "1.47.0", features = ["sync"], optional = true }
indexmap = { workspace = true, optional = true } indexmap = { version = "2.13.0", optional = true }
lru = { workspace = true, optional = true } lru = { version = "0.16.3", optional = true }
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true, features = [ tokio = { version = "1.47.0", features = [
"rt", "rt",
"rt-multi-thread", "rt-multi-thread",
"macros", "macros",
+12 -12
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-config" name = "revolt-config"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -18,24 +18,24 @@ default = ["test", "sentry"]
[dependencies] [dependencies]
# Utility # Utility
config = { workspace = true } config = "0.13.3"
cached = { workspace = true } cached = "0.44.0"
once_cell = { workspace = true } once_cell = "1.18.0"
# Serde # Serde
serde = { workspace = true } serde = { version = "1", features = ["derive"] }
# Async # Async
futures-locks = { workspace = true } futures-locks = "0.7.1"
async-std = { workspace = true, features = ["attributes"], optional = true } async-std = { version = "1.8.0", features = ["attributes"], optional = true }
# Logging # Logging
log = { workspace = true } log = "0.4.14"
pretty_env_logger = { workspace = true } pretty_env_logger = "0.4.0"
# Sentry # Sentry
sentry = { workspace = true, optional = true } sentry = { version = "0.31.5", optional = true }
sentry-anyhow = { workspace = true, optional = true } sentry-anyhow = { version = "0.38.1", optional = true }
# Core # Core
revolt-result = { workspace = true, optional = true } revolt-result = { version = "0.12.0", path = "../result", optional = true }
-14
View File
@@ -30,10 +30,6 @@ host = "rabbit"
port = 5672 port = 5672
username = "rabbituser" username = "rabbituser"
password = "rabbitpass" password = "rabbitpass"
default_exchange = "revolt.default"
[rabbit.queues]
acks = "internal.ack"
[api] [api]
@@ -82,8 +78,6 @@ call_ring_duration = 30
[api.livekit.nodes] [api.livekit.nodes]
[api.users] [api.users]
# Minimum allowed length of usernames
min_username_length = 2
[pushd] [pushd]
# this changes the names of the queues to not overlap # this changes the names of the queues to not overlap
@@ -136,8 +130,6 @@ pkcs8 = ""
key_id = "" key_id = ""
team_id = "" team_id = ""
[january]
blocked_domains = []
[files] [files]
# Encryption key for stored files # Encryption key for stored files
@@ -321,12 +313,6 @@ emojis = 500_000
# default: 5 # default: 5
process_message_delay_limit = 5 process_message_delay_limit = 5
[features.legal_links]
# URLs for legal documents
terms_of_service = ""
privacy_policy = ""
guidelines = ""
[sentry] [sentry]
# Configuration for Sentry error reporting # Configuration for Sentry error reporting
api = "" api = ""
-26
View File
@@ -122,19 +122,12 @@ pub struct Database {
pub redis_pubsub: Option<String>, pub redis_pubsub: Option<String>,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Rabbit { pub struct Rabbit {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub username: String, pub username: String,
pub password: String, pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -238,7 +231,6 @@ pub struct LiveKitNode {
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers { pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>, pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -309,11 +301,6 @@ impl Pushd {
} }
} }
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit { pub struct FilesLimit {
pub min_file_size: usize, pub min_file_size: usize,
@@ -389,16 +376,6 @@ pub struct FeaturesLimitsCollection {
pub roles: HashMap<String, FeaturesLimits>, pub roles: HashMap<String, FeaturesLimits>,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced { pub struct FeaturesAdvanced {
#[serde(default)] #[serde(default)]
@@ -416,7 +393,6 @@ impl Default for FeaturesAdvanced {
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Features { pub struct Features {
pub limits: FeaturesLimitsCollection, pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool, pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool, pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool, pub mass_mentions_enabled: bool,
@@ -444,7 +420,6 @@ pub struct Settings {
pub hosts: Hosts, pub hosts: Hosts,
pub api: Api, pub api: Api,
pub pushd: Pushd, pub pushd: Pushd,
pub january: January,
pub files: Files, pub files: Files,
pub features: Features, pub features: Features,
pub sentry: Sentry, pub sentry: Sentry,
@@ -505,7 +480,6 @@ pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry:
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0"); std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
} }
pretty_env_logger::init();
log::info!("Starting {release}"); log::info!("Starting {release}");
if dsn.is_empty() { if dsn.is_empty() {
+54 -46
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-database" name = "revolt-database"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -32,72 +32,80 @@ default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies] [dependencies]
# Core # Core
revolt-config = { workspace = true, features = ["report-macros"] } revolt-config = { version = "0.12.0", path = "../config", features = [
revolt-result = { workspace = true } "report-macros",
revolt-models = { workspace = true, features = ["validator"] } ] }
revolt-presence = { workspace = true } revolt-result = { version = "0.12.0", path = "../result" }
revolt-permissions = { workspace = true, features = ["serde", "bson"] } revolt-models = { version = "0.12.0", path = "../models", features = [
revolt-parser = { workspace = true } "validator",
revolt-coalesced = { workspace = true } ] }
revolt-presence = { version = "0.12.0", path = "../presence" }
revolt-permissions = { version = "0.12.0", path = "../permissions", features = [
"serde",
"bson",
] }
revolt-parser = { version = "0.12.0", path = "../parser" }
# Utility # Utility
log = { workspace = true } log = "0.4"
lru = { workspace = true } lru = "0.11.0"
rand = { workspace = true } rand = "0.8.5"
ulid = { workspace = true } ulid = "1.0.0"
nanoid = { workspace = true } nanoid = "0.4.0"
base64 = { workspace = true } base64 = "0.21.3"
once_cell = { workspace = true } once_cell = "1.17"
indexmap = { workspace = true } indexmap = "1.9.1"
decancer = { workspace = true } decancer = "1.6.2"
deadqueue = { workspace = true } deadqueue = "0.2.4"
linkify = { workspace = true, optional = true } linkify = { optional = true, version = "0.8.1" }
url-escape = { workspace = true, optional = true } url-escape = { optional = true, version = "0.1.1" }
validator = { workspace = true, features = ["derive"] } validator = { version = "0.16", features = ["derive"] }
isahc = { workspace = true, features = ["json"], optional = true } isahc = { optional = true, version = "1.7", features = ["json"] }
# Serialisation # Serialisation
serde_json = { workspace = true } serde_json = "1"
revolt_optional_struct = { workspace = true } revolt_optional_struct = "0.2.0"
serde = { workspace = true } serde = { version = "1", features = ["derive"] }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] } iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] }
# Events # Events
redis-kiss = { workspace = true } redis-kiss = { version = "0.1.4" }
# Database # Database
bson = { workspace = true, optional = true } bson = { optional = true, version = "2.1.0" }
mongodb = { workspace = true, optional = true } mongodb = { optional = true, version = "3.1.0" }
# Database Migration # Database Migration
unicode-segmentation = { workspace = true } unicode-segmentation = "1.10.1"
regex = { workspace = true } regex = "1"
# Async Language Features # Async Language Features
futures = { workspace = true } futures = "0.3.19"
async-lock = { workspace = true } async-lock = "2.8.0"
async-trait = { workspace = true } async-trait = "0.1.51"
async-recursion = { workspace = true } async-recursion = "1.0.4"
# Async # Async
async-std = { workspace = true, features = ["attributes"], optional = true } async-std = { version = "1.8.0", features = ["attributes"], optional = true }
# Axum Impl # Axum Impl
axum = { workspace = true, optional = true } axum = { version = "0.7.5", optional = true }
# Rocket Impl # Rocket Impl
schemars = { workspace = true, optional = true } schemars = { version = "0.8.8", optional = true }
rocket = { workspace = true, features = ["json"], optional = true } rocket = { version = "0.5.1", default-features = false, features = [
revolt_okapi = { workspace = true, optional = true } "json",
revolt_rocket_okapi = { workspace = true, optional = true } ], optional = true }
revolt_okapi = { version = "0.9.1", optional = true }
revolt_rocket_okapi = { version = "0.10.0", optional = true }
# Authifier # Authifier
authifier = { workspace = true } authifier = { version = "1.0.16" }
# RabbitMQ # RabbitMQ
lapin = { workspace = true, features = ["tokio"] } amqprs = { version = "1.7.0" }
# Voice # Voice
livekit-api = { workspace = true, features = ["rustls-tls-native-roots"], optional = true } livekit-api = { version = "0.4.4", optional = true }
livekit-protocol = { workspace = true, optional = true } livekit-protocol = { version = "0.4.0", optional = true }
livekit-runtime = { workspace = true, features = ["tokio"], optional = true } livekit-runtime = { version = "0.3.1", features = ["tokio"], optional = true }
+107 -170
View File
@@ -1,77 +1,58 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc;
use crate::events::rabbit::*; use crate::events::rabbit::*;
use crate::User; use crate::User;
use lapin::{ use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments};
options::BasicPublishOptions, use amqprs::connection::OpenConnectionArguments;
protocol::basic::AMQPProperties, use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError};
types::{AMQPValue, FieldTable}, use amqprs::{BasicProperties, FieldTable};
Channel, Connection, ConnectionProperties, Error as AMQPError,
};
use revolt_models::v0::PushNotification; use revolt_models::v0::PushNotification;
use revolt_presence::filter_online; use revolt_presence::filter_online;
use revolt_result::Result;
use serde_json::to_string; use serde_json::to_string;
#[derive(Clone)] #[derive(Clone)]
pub struct AMQP { pub struct AMQP {
friend_request_accepted: Arc<Channel>,
friend_request_received: Arc<Channel>,
generic_message: Arc<Channel>,
message_sent: Arc<Channel>,
mass_mention_message_sent: Arc<Channel>,
ack_notification_message: Arc<Channel>,
dm_call_updated: Arc<Channel>,
process_ack: Arc<Channel>,
#[allow(unused)] #[allow(unused)]
connection: Arc<Connection>, connection: Connection,
channel: Channel,
} }
impl AMQP { impl AMQP {
pub async fn new(connection: Arc<Connection>) -> Self { pub fn new(connection: Connection, channel: Channel) -> AMQP {
Self { AMQP {
friend_request_accepted: Self::create_channel(&connection).await,
friend_request_received: Self::create_channel(&connection).await,
generic_message: Self::create_channel(&connection).await,
message_sent: Self::create_channel(&connection).await,
mass_mention_message_sent: Self::create_channel(&connection).await,
ack_notification_message: Self::create_channel(&connection).await,
dm_call_updated: Self::create_channel(&connection).await,
process_ack: Self::create_channel(&connection).await,
connection, connection,
channel,
} }
} }
pub async fn new_auto() -> Self { pub async fn new_auto() -> AMQP {
let config = revolt_config::config().await; let config = revolt_config::config().await;
let connection = Arc::new( let connection = Connection::open(&OpenConnectionArguments::new(
Connection::connect( &config.rabbit.host,
&format!( config.rabbit.port,
"amqp://{}:{}@{}:{}", &config.rabbit.username,
&config.rabbit.username, &config.rabbit.password,
&config.rabbit.password, ))
&config.rabbit.host, .await
&config.rabbit.port, .expect("Failed to connect to RabbitMQ");
),
ConnectionProperties::default(), let channel = connection
.open_channel(None)
.await
.expect("Failed to open RabbitMQ channel");
channel
.exchange_declare(
ExchangeDeclareArguments::new(&config.pushd.exchange, "direct")
.durable(true)
.finish(),
) )
.await .await
.expect("Failed to connect to RabbitMQ"), .expect("Failed to declare exchange");
);
Self::new(connection).await AMQP::new(connection, channel)
}
async fn create_channel(connection: &Connection) -> Arc<Channel> {
Arc::new(
connection
.create_channel()
.await
.expect("Failed to create channel"),
)
} }
pub async fn friend_request_accepted( pub async fn friend_request_accepted(
@@ -91,20 +72,19 @@ impl AMQP {
config.pushd.get_fr_accepted_routing_key(), config.pushd.get_fr_accepted_routing_key(),
payload payload
); );
self.channel
self.friend_request_accepted
.basic_publish( .basic_publish(
config.pushd.exchange.clone().into(), BasicProperties::default()
config.pushd.get_fr_accepted_routing_key().into(), .with_content_type("application/json")
BasicPublishOptions::default(), .with_persistence(true)
payload.as_bytes(), .finish(),
AMQPProperties::default() payload.into(),
.with_content_type("application/json".into()) BasicPublishArguments::new(
.with_delivery_mode(2), &config.pushd.exchange,
&config.pushd.get_fr_accepted_routing_key(),
),
) )
.await?; .await
Ok(())
} }
pub async fn friend_request_received( pub async fn friend_request_received(
@@ -125,19 +105,19 @@ impl AMQP {
payload payload
); );
self.friend_request_received self.channel
.basic_publish( .basic_publish(
config.pushd.exchange.clone().into(), BasicProperties::default()
config.pushd.get_fr_received_routing_key().into(), .with_content_type("application/json")
BasicPublishOptions::default(), .with_persistence(true)
payload.as_bytes(), .finish(),
AMQPProperties::default() payload.into(),
.with_content_type("application/json".into()) BasicPublishArguments::new(
.with_delivery_mode(2), &config.pushd.exchange,
&config.pushd.get_fr_received_routing_key(),
),
) )
.await?; .await
Ok(())
} }
pub async fn generic_message( pub async fn generic_message(
@@ -162,19 +142,19 @@ impl AMQP {
payload payload
); );
self.generic_message self.channel
.basic_publish( .basic_publish(
config.pushd.exchange.clone().into(), BasicProperties::default()
config.pushd.get_generic_routing_key().into(), .with_content_type("application/json")
BasicPublishOptions::default(), .with_persistence(true)
payload.as_bytes(), .finish(),
AMQPProperties::default() payload.into(),
.with_content_type("application/json".into()) BasicPublishArguments::new(
.with_delivery_mode(2), &config.pushd.exchange,
&config.pushd.get_generic_routing_key(),
),
) )
.await?; .await
Ok(())
} }
pub async fn message_sent( pub async fn message_sent(
@@ -205,19 +185,19 @@ impl AMQP {
payload payload
); );
self.message_sent self.channel
.basic_publish( .basic_publish(
config.pushd.exchange.clone().into(), BasicProperties::default()
config.pushd.get_message_routing_key().into(), .with_content_type("application/json")
BasicPublishOptions::default(), .with_persistence(true)
payload.as_bytes(), .finish(),
AMQPProperties::default() payload.into(),
.with_content_type("application/json".into()) BasicPublishArguments::new(
.with_delivery_mode(2), &config.pushd.exchange,
&config.pushd.get_message_routing_key(),
),
) )
.await?; .await
Ok(())
} }
pub async fn mass_mention_message_sent( pub async fn mass_mention_message_sent(
@@ -240,24 +220,19 @@ impl AMQP {
routing_key, payload routing_key, payload
); );
self.mass_mention_message_sent self.channel
.basic_publish( .basic_publish(
config.pushd.exchange.clone().into(), BasicProperties::default()
routing_key.into(), .with_content_type("application/json")
BasicPublishOptions::default(), .with_persistence(true)
payload.as_bytes(), .finish(),
AMQPProperties::default() payload.into(),
.with_content_type("application/json".into()) BasicPublishArguments::new(&config.pushd.exchange, routing_key.as_str()),
.with_delivery_mode(2),
) )
.await?; .await
Ok(())
} }
/// # Sends an ack to pushd to update badges on iPhones. pub async fn ack_message(
/// Not to be confused with the process_ack function, which handles sending all acks to crond for processing.
pub async fn ack_notification_message(
&self, &self,
user_id: String, user_id: String,
channel_id: String, channel_id: String,
@@ -277,25 +252,23 @@ impl AMQP {
config.pushd.ack_queue, payload config.pushd.ack_queue, payload
); );
let mut headers = FieldTable::default(); let mut headers = FieldTable::new();
headers.insert( headers.insert(
"x-deduplication-header".into(), "x-deduplication-header".try_into().unwrap(),
AMQPValue::LongString(format!("{}-{}", &user_id, &channel_id).into()), format!("{}-{}", &user_id, &channel_id).into(),
); );
self.ack_notification_message self.channel
.basic_publish( .basic_publish(
config.pushd.exchange.clone().into(), BasicProperties::default()
config.pushd.ack_queue.into(), .with_content_type("application/json")
BasicPublishOptions::default(), .with_persistence(true)
payload.as_bytes(), //.with_headers(headers)
AMQPProperties::default() .finish(),
.with_content_type("application/json".into()) payload.into(),
.with_delivery_mode(2), BasicPublishArguments::new(&config.pushd.exchange, &config.pushd.ack_queue),
) )
.await?; .await
Ok(())
} }
/// # DM Call Update /// # DM Call Update
@@ -329,54 +302,18 @@ impl AMQP {
payload payload
); );
self.dm_call_updated self.channel
.basic_publish( .basic_publish(
config.pushd.exchange.clone().into(), BasicProperties::default()
config.pushd.get_dm_call_routing_key().into(), .with_content_type("application/json")
BasicPublishOptions::default(), .with_persistence(true)
payload.as_bytes(), .finish(),
AMQPProperties::default() payload.into(),
.with_content_type("application/json".into()) BasicPublishArguments::new(
.with_delivery_mode(2), &config.pushd.exchange,
&config.pushd.get_dm_call_routing_key(),
),
) )
.await?; .await
Ok(())
}
/// # Send an ack to crond for processing
pub async fn process_ack(
&self,
user_id: &str,
channel_id: Option<&str>,
server_id: Option<&str>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = AckEventPayload {
user_id: user_id.to_string(),
channel_id: channel_id.map(|value| value.to_string()),
server_id: server_id.map(|value| value.to_string()),
};
let payload = to_string(&payload).unwrap();
info!(
"Sending ack processor event on exchange {}, channel {}: {}",
config.rabbit.default_exchange, config.rabbit.queues.acks, payload
);
self.process_ack
.basic_publish(
config.rabbit.default_exchange.clone().into(),
config.rabbit.queues.acks.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
} }
} }
+22 -84
View File
@@ -3,12 +3,7 @@ use revolt_result::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use revolt_models::v0::{ use revolt_models::v0::{
AppendMessage, Channel, ChannelSlowmode, ChannelUnread, ChannelVoiceState, Emoji, AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser,
FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji,
PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState,
PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings,
UserVoiceState, Webhook,
}; };
use crate::Database; use crate::Database;
@@ -56,13 +51,9 @@ impl Default for ReadyPayloadFields {
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum EventV1 { pub enum EventV1 {
/// Multiple events /// Multiple events
Bulk { Bulk { v: Vec<EventV1> },
v: Vec<EventV1>,
},
/// Error event /// Error event
Error { Error { data: Error },
data: Error,
},
/// Successfully authenticated /// Successfully authenticated
Authenticated, Authenticated,
@@ -93,9 +84,7 @@ pub enum EventV1 {
}, },
/// Ping response /// Ping response
Pong { Pong { data: Ping },
data: Ping,
},
/// New message /// New message
Message(Message), Message(Message),
@@ -116,10 +105,7 @@ pub enum EventV1 {
}, },
/// Delete message /// Delete message
MessageDelete { MessageDelete { id: String, channel: String },
id: String,
channel: String,
},
/// New reaction to a message /// New reaction to a message
MessageReact { MessageReact {
@@ -145,10 +131,7 @@ pub enum EventV1 {
}, },
/// Bulk delete messages /// Bulk delete messages
BulkMessageDelete { BulkMessageDelete { channel: String, ids: Vec<String> },
channel: String,
ids: Vec<String>,
},
/// New server /// New server
ServerCreate { ServerCreate {
@@ -156,7 +139,7 @@ pub enum EventV1 {
server: Server, server: Server,
channels: Vec<Channel>, channels: Vec<Channel>,
emojis: Vec<Emoji>, emojis: Vec<Emoji>,
voice_states: Vec<ChannelVoiceState>, voice_states: Vec<ChannelVoiceState>
}, },
/// Update existing server /// Update existing server
@@ -168,9 +151,7 @@ pub enum EventV1 {
}, },
/// Delete server /// Delete server
ServerDelete { ServerDelete { id: String },
id: String,
},
/// Update existing server member /// Update existing server member
ServerMemberUpdate { ServerMemberUpdate {
@@ -206,16 +187,10 @@ pub enum EventV1 {
}, },
/// Server role deleted /// Server role deleted
ServerRoleDelete { ServerRoleDelete { id: String, role_id: String },
id: String,
role_id: String,
},
/// Server roles ranks updated /// Server roles ranks updated
ServerRoleRanksUpdate { ServerRoleRanksUpdate { id: String, ranks: Vec<String> },
id: String,
ranks: Vec<String>,
},
/// Update existing user /// Update existing user
UserUpdate { UserUpdate {
@@ -227,15 +202,9 @@ pub enum EventV1 {
}, },
/// Relationship with another user changed /// Relationship with another user changed
UserRelationship { UserRelationship { id: String, user: User },
id: String,
user: User,
},
/// Settings updated remotely /// Settings updated remotely
UserSettingsUpdate { UserSettingsUpdate { id: String, update: UserSettings },
id: String,
update: UserSettings,
},
/// User has been platform banned or deleted their account /// User has been platform banned or deleted their account
/// ///
@@ -246,23 +215,12 @@ pub enum EventV1 {
/// - Server Memberships /// - Server Memberships
/// ///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear. /// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe { UserPlatformWipe { user_id: String, flags: i32 },
user_id: String,
flags: i32,
},
/// New emoji /// New emoji
EmojiCreate(Emoji), EmojiCreate(Emoji),
/// Update existing emoji
EmojiUpdate {
id: String,
data: PartialEmoji,
},
/// Delete emoji /// Delete emoji
EmojiDelete { EmojiDelete { id: String },
id: String,
},
/// New report /// New report
ReportCreate(Report), ReportCreate(Report),
@@ -278,33 +236,19 @@ pub enum EventV1 {
}, },
/// Delete channel /// Delete channel
ChannelDelete { ChannelDelete { id: String },
id: String,
},
/// User joins a group /// User joins a group
ChannelGroupJoin { ChannelGroupJoin { id: String, user: String },
id: String,
user: String,
},
/// User leaves a group /// User leaves a group
ChannelGroupLeave { ChannelGroupLeave { id: String, user: String },
id: String,
user: String,
},
/// User started typing in a channel /// User started typing in a channel
ChannelStartTyping { ChannelStartTyping { id: String, user: String },
id: String,
user: String,
},
/// User stopped typing in a channel /// User stopped typing in a channel
ChannelStopTyping { ChannelStopTyping { id: String, user: String },
id: String,
user: String,
},
/// User acknowledged message in channel /// User acknowledged message in channel
ChannelAck { ChannelAck {
@@ -324,9 +268,7 @@ pub enum EventV1 {
}, },
/// Delete webhook /// Delete webhook
WebhookDelete { WebhookDelete { id: String },
id: String,
},
/// Auth events /// Auth events
Auth(AuthifierEvent), Auth(AuthifierEvent),
@@ -344,7 +286,7 @@ pub enum EventV1 {
user: String, user: String,
from: String, from: String,
to: String, to: String,
state: UserVoiceState, state: UserVoiceState
}, },
UserVoiceStateUpdate { UserVoiceStateUpdate {
id: String, id: String,
@@ -356,11 +298,7 @@ pub enum EventV1 {
from: String, from: String,
to: String, to: String,
token: String, token: String,
}, }
/// User's active slowmodes
UserSlowmodes {
slowmodes: Vec<ChannelSlowmode>,
},
} }
impl EventV1 { impl EventV1 {
@@ -78,11 +78,3 @@ pub struct AckPayload {
pub channel_id: String, pub channel_id: String,
pub message_id: String, pub message_id: String,
} }
/// This is not the same as the AckPayload above, as the state for this event is stored in redis to allow for state updates while the event is queued.
#[derive(Serialize, Deserialize, Debug)]
pub struct AckEventPayload {
pub user_id: String,
pub channel_id: Option<String>,
pub server_id: Option<String>,
}
@@ -1,7 +1,6 @@
#![allow(deprecated)] #![allow(deprecated)]
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap};
use redis_kiss::get_connection;
use revolt_config::config; use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor}; use revolt_models::v0::{self, MessageAuthor};
use revolt_permissions::OverrideField; use revolt_permissions::OverrideField;
@@ -213,7 +212,7 @@ impl Channel {
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()), voice: data.voice.map(|voice| voice.into()),
slowmode: None, slowmode: None
}, },
v0::LegacyServerChannelType::Voice => Channel::TextChannel { v0::LegacyServerChannelType::Voice => Channel::TextChannel {
id: id.clone(), id: id.clone(),
@@ -226,7 +225,7 @@ impl Channel {
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()), voice: Some(data.voice.unwrap_or_default().into()),
slowmode: None, slowmode: None
}, },
}; };
@@ -644,7 +643,7 @@ impl Channel {
} }
/// Acknowledge a message /// Acknowledge a message
pub async fn ack(&self, user: &str, message: &str, amqp: &AMQP) -> Result<()> { pub async fn ack(&self, user: &str, message: &str) -> Result<()> {
EventV1::ChannelAck { EventV1::ChannelAck {
id: self.id().to_string(), id: self.id().to_string(),
user: user.to_string(), user: user.to_string(),
@@ -653,7 +652,17 @@ impl Channel {
.private(user.to_string()) .private(user.to_string())
.await; .await;
crate::util::acker::ack_channel(user, self.id(), message, amqp).await #[cfg(feature = "tasks")]
crate::tasks::ack::queue_ack(
self.id().to_string(),
user.to_string(),
crate::tasks::ack::AckEvent::AckMessage {
id: message.to_string(),
},
)
.await;
Ok(())
} }
/// Remove user from a group /// Remove user from a group
@@ -2,7 +2,6 @@ use std::collections::HashSet;
use std::str::FromStr; use std::str::FromStr;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use revolt_models::v0;
use revolt_result::Result; use revolt_result::Result;
use ulid::Ulid; use ulid::Ulid;
@@ -12,7 +11,7 @@ use crate::Database;
static PERMISSIBLE_EMOJIS: Lazy<HashSet<String>> = Lazy::new(|| { static PERMISSIBLE_EMOJIS: Lazy<HashSet<String>> = Lazy::new(|| {
include_str!("unicode_emoji.txt") include_str!("unicode_emoji.txt")
.split('\n') .split('\n')
.map(|x| x.replace('\u{FE0F}', "")) .map(|x| x.into())
.collect() .collect()
}); });
@@ -42,12 +41,6 @@ auto_derived!(
Server { id: String }, Server { id: String },
Detached, Detached,
} }
/// Partial representation of an emoji
pub struct PartialEmoji {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
); );
#[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_methods)]
@@ -82,34 +75,13 @@ impl Emoji {
db.detach_emoji(&self).await db.detach_emoji(&self).await
} }
/// Update an emoji
pub async fn update(&mut self, db: &Database, partial: PartialEmoji) -> Result<()> {
if let Some(name) = partial.name.clone() {
self.name = name;
}
db.update_emoji(&self.id, &partial).await?;
EventV1::EmojiUpdate {
id: self.id.clone(),
data: v0::PartialEmoji {
name: partial.name.clone(),
},
}
.p(self.parent().to_string())
.await;
Ok(())
}
/// Check whether we can use a given emoji /// Check whether we can use a given emoji
pub async fn can_use(db: &Database, emoji: &str) -> Result<bool> { pub async fn can_use(db: &Database, emoji: &str) -> Result<bool> {
if Ulid::from_str(emoji).is_ok() { if Ulid::from_str(emoji).is_ok() {
db.fetch_emoji(emoji).await?; db.fetch_emoji(emoji).await?;
Ok(true) Ok(true)
} else { } else {
let sanitized_emoji = emoji.replace('\u{FE0F}', ""); Ok(PERMISSIBLE_EMOJIS.contains(emoji))
Ok(PERMISSIBLE_EMOJIS.contains(&sanitized_emoji))
} }
} }
} }
@@ -1,6 +1,6 @@
use revolt_result::Result; use revolt_result::Result;
use crate::{Emoji, PartialEmoji}; use crate::Emoji;
#[cfg(feature = "mongodb")] #[cfg(feature = "mongodb")]
mod mongodb; mod mongodb;
@@ -20,9 +20,6 @@ pub trait AbstractEmojis: Sync + Send {
/// Fetch emoji by their parent ids /// Fetch emoji by their parent ids
async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result<Vec<Emoji>>; async fn fetch_emoji_by_parent_ids(&self, parent_ids: &[String]) -> Result<Vec<Emoji>>;
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()>;
/// Detach an emoji by its id /// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()>; async fn detach_emoji(&self, emoji: &Emoji) -> Result<()>;
} }
@@ -1,7 +1,7 @@
use bson::Document; use bson::Document;
use revolt_result::Result; use revolt_result::Result;
use crate::{Emoji, PartialEmoji}; use crate::Emoji;
use crate::MongoDb; use crate::MongoDb;
use super::AbstractEmojis; use super::AbstractEmojis;
@@ -46,11 +46,6 @@ impl AbstractEmojis for MongoDb {
) )
} }
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
query!(self, update_one_by_id, COL, emoji_id, partial, vec![], None).map(|_| ())
}
/// Detach an emoji by its id /// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> { async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
self.col::<Document>(COL) self.col::<Document>(COL)
@@ -1,6 +1,6 @@
use revolt_result::Result; use revolt_result::Result;
use crate::{Emoji, PartialEmoji}; use crate::Emoji;
use crate::EmojiParent; use crate::EmojiParent;
use crate::ReferenceDb; use crate::ReferenceDb;
@@ -54,19 +54,6 @@ impl AbstractEmojis for ReferenceDb {
.collect()) .collect())
} }
/// Update emoji with new information
async fn update_emoji(&self, emoji_id: &str, partial: &PartialEmoji) -> Result<()> {
let mut emojis = self.emojis.lock().await;
if let Some(emoji) = emojis.get_mut(emoji_id) {
if let Some(name) = partial.name.clone() {
emoji.name = name;
}
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Detach an emoji by its id /// Detach an emoji by its id
async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> { async fn detach_emoji(&self, emoji: &Emoji) -> Result<()> {
let mut emojis = self.emojis.lock().await; let mut emojis = self.emojis.lock().await;
File diff suppressed because it is too large. Load diff
@@ -46,7 +46,8 @@ auto_derived!(
width: isize, width: isize,
height: isize, height: isize,
thumbhash: Option<Vec<u8>>, thumbhash: Option<Vec<u8>>,
animated: Option<bool>, #[serde(default)]
animated: bool,
}, },
/// File is a video with specific dimensions /// File is a video with specific dimensions
Video { width: isize, height: isize }, Video { width: isize, height: isize },
@@ -17,12 +17,6 @@ pub trait AbstractAttachmentHashes: Sync + Send {
/// Update an attachment hash nonce value. /// Update an attachment hash nonce value.
async fn set_attachment_hash_nonce(&self, hash: &str, nonce: &str) -> Result<()>; async fn set_attachment_hash_nonce(&self, hash: &str, nonce: &str) -> Result<()>;
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()>;
/// Delete attachment hash by id. /// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()>; async fn delete_attachment_hash(&self, id: &str) -> Result<()>;
} }
@@ -48,29 +48,6 @@ impl AbstractAttachmentHashes for MongoDb {
.map_err(|_| create_database_error!("update_one", COL)) .map_err(|_| create_database_error!("update_one", COL))
} }
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()> {
self.col::<FileHash>(COL)
.update_one(
doc! {
"_id": hash,
"metadata.type": "Image",
"metadata.animated": { "$exists": false },
},
doc! {
"$set": {
"metadata.animated": animated
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
/// Delete attachment hash by id. /// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()> { async fn delete_attachment_hash(&self, id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, id).map(|_| ()) query!(self, delete_one_by_id, COL, id).map(|_| ())
@@ -1,6 +1,7 @@
use revolt_result::Result; use revolt_result::Result;
use crate::{FileHash, Metadata, ReferenceDb}; use crate::FileHash;
use crate::ReferenceDb;
use super::AbstractAttachmentHashes; use super::AbstractAttachmentHashes;
@@ -38,29 +39,6 @@ impl AbstractAttachmentHashes for ReferenceDb {
} }
} }
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()> {
let mut hashes = self.file_hashes.lock().await;
if let Some(FileHash {
metadata:
Metadata::Image {
animated: Some(animated_metadata),
..
},
..
}) = hashes.get_mut(hash)
{
*animated_metadata = animated;
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Delete attachment hash by id. /// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()> { async fn delete_attachment_hash(&self, id: &str) -> Result<()> {
let mut file_hashes = self.file_hashes.lock().await; let mut file_hashes = self.file_hashes.lock().await;
@@ -70,7 +70,6 @@ auto_derived!(
LegacyGroupIcon, LegacyGroupIcon,
ChannelIcon, ChannelIcon,
ServerIcon, ServerIcon,
RoleIcon,
} }
/// Information about what the file was used for /// Information about what the file was used for
@@ -240,23 +239,4 @@ impl File {
) )
.await .await
} }
/// Use a file for a role icon
pub async fn use_role_icon(
db: &Database,
id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"icons",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::RoleIcon,
},
uploader_id.to_owned(),
)
.await
}
} }
@@ -249,7 +249,7 @@ impl AbstractMessages for ReferenceDb {
let mut messages = self.messages.lock().await; let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) { if let Some(message) = messages.get_mut(id) {
if let Some(users) = message.reactions.get_mut(emoji) { if let Some(users) = message.reactions.get_mut(emoji) {
users.swap_remove(&user.to_string()); users.remove(&user.to_string());
} }
Ok(()) Ok(())
@@ -262,7 +262,7 @@ impl AbstractMessages for ReferenceDb {
async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> { async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> {
let mut messages = self.messages.lock().await; let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) { if let Some(message) = messages.get_mut(id) {
message.reactions.swap_remove(emoji); message.reactions.remove(emoji);
Ok(()) Ok(())
} else { } else {
Err(create_error!(NotFound)) Err(create_error!(NotFound))
@@ -86,9 +86,6 @@ auto_derived_partial!(
/// Ranking of this role /// Ranking of this role
#[serde(default)] #[serde(default)]
pub rank: i64, pub rank: i64,
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<File>,
}, },
"PartialRole" "PartialRole"
); );
@@ -132,7 +129,6 @@ auto_derived!(
/// Optional fields on server object /// Optional fields on server object
pub enum FieldsRole { pub enum FieldsRole {
Colour, Colour,
Icon,
} }
); );
@@ -309,7 +305,6 @@ impl Role {
colour: self.colour, colour: self.colour,
hoist: Some(self.hoist), hoist: Some(self.hoist),
rank: Some(self.rank), rank: Some(self.rank),
icon: self.icon,
} }
} }
@@ -323,7 +318,6 @@ impl Role {
colour: None, colour: None,
hoist: false, hoist: false,
permissions: Default::default(), permissions: Default::default(),
icon: None,
}; };
db.insert_role(&server.id, &role).await?; db.insert_role(&server.id, &role).await?;
@@ -373,7 +367,6 @@ impl Role {
pub fn remove_field(&mut self, field: &FieldsRole) { pub fn remove_field(&mut self, field: &FieldsRole) {
match field { match field {
FieldsRole::Colour => self.colour = None, FieldsRole::Colour => self.colour = None,
FieldsRole::Icon => self.icon = None,
} }
} }
@@ -77,7 +77,7 @@ impl AbstractServers for MongoDb {
}, },
doc! { doc! {
"$set": { "$set": {
"roles.".to_owned() + role.id.as_str(): to_document(role) "roles.".to_owned() + &role.id: to_document(role)
.map_err(|_| create_database_error!("to_document", "role"))? .map_err(|_| create_database_error!("to_document", "role"))?
} }
}, },
@@ -172,7 +172,6 @@ impl IntoDocumentPath for FieldsRole {
fn as_path(&self) -> Option<&'static str> { fn as_path(&self) -> Option<&'static str> {
Some(match self { Some(match self {
FieldsRole::Colour => "colour", FieldsRole::Colour => "colour",
FieldsRole::Icon => "icon",
}) })
} }
} }
+34 -153
View File
@@ -7,7 +7,6 @@ use futures::future::join_all;
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use regex::{Regex, RegexBuilder};
use revolt_config::{config, FeaturesLimits}; use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{self, UserBadges, UserFlags}; use revolt_models::v0::{self, UserBadges, UserFlags};
use revolt_presence::filter_online; use revolt_presence::filter_online;
@@ -164,13 +163,6 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
set.into_iter().collect() set.into_iter().collect()
}); });
static BLOCKED_USERNAME_PATTERNS: Lazy<Regex> = Lazy::new(|| {
RegexBuilder::new("`{3}|(discord|rvlt|guilded|stt)\\.gg|(revolt|stoat)\\.chat|https?:\\/\\/")
.case_insensitive(true)
.build()
.unwrap()
});
#[allow(clippy::derivable_impls)] #[allow(clippy::derivable_impls)]
impl Default for User { impl Default for User {
fn default() -> Self { fn default() -> Self {
@@ -206,13 +198,11 @@ impl User {
I: Into<Option<String>>, I: Into<Option<String>>,
D: Into<Option<PartialUser>>, D: Into<Option<PartialUser>>,
{ {
let new_username = User::sanitise_username(&username).await?; let username = User::validate_username(username)?;
User::validate_username(&new_username)?;
let mut user = User { let mut user = User {
id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()), id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()),
discriminator: User::find_discriminator(db, &new_username, None).await?, discriminator: User::find_discriminator(db, &username, None).await?,
username: new_username.clone(), username,
last_acknowledged_policy_change: Timestamp::now_utc(), last_acknowledged_policy_change: Timestamp::now_utc(),
..Default::default() ..Default::default()
}; };
@@ -288,40 +278,39 @@ impl User {
} }
} }
/// Validate a username /// Sanitise and validate a username can be used
/// pub fn validate_username(username: String) -> Result<String> {
/// This will check if the username is a blocked name or contains a blocked pattern. // Copy the username for validation
fn validate_username(username: &str) -> Result<()> {
let username_lowercase = username.to_lowercase(); let username_lowercase = username.to_lowercase();
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"]; // Block homoglyphs
if decancer::cure(&username_lowercase).into_str() != username_lowercase {
if BLOCKED_USERNAMES.contains(&username_lowercase.as_str())
|| BLOCKED_USERNAME_PATTERNS.is_match(username)
{
return Err(create_error!(InvalidUsername)); return Err(create_error!(InvalidUsername));
} }
Ok(()) // Ensure the username itself isn't blocked
} const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"];
/// Sanitise a username for username in BLOCKED_USERNAMES {
/// if username_lowercase == *username {
/// This will clean up Unicode homoglyphs and pad to the min username length with underscores. return Err(create_error!(InvalidUsername));
async fn sanitise_username(username: &str) -> Result<String> { }
let options = decancer::Options::default().retain_capitalization(); }
let mut username = decancer::cure(username, options)
.map_err(|_| create_error!(InvalidUsername))?
.to_string();
let config = revolt_config::config().await; // Ensure none of the following substrings show up in the username
let username_length_diff = config const BLOCKED_SUBSTRINGS: &[&str] = &[
.api "```",
.users "discord.gg",
.min_username_length "rvlt.gg",
.saturating_sub(username.len()); "guilded.gg",
if username_length_diff > 0 { "https://",
username.push_str(&"_".repeat(username_length_diff)) "http://",
];
for substr in BLOCKED_SUBSTRINGS {
if username_lowercase.contains(substr) {
return Err(create_error!(InvalidUsername));
}
} }
Ok(username) Ok(username)
@@ -427,14 +416,12 @@ impl User {
/// Update a user's username /// Update a user's username
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> { pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
let new_username = User::sanitise_username(&username).await?; let username = User::validate_username(username)?;
User::validate_username(&new_username)?; if self.username.to_lowercase() == username.to_lowercase() {
if self.username.to_lowercase() == new_username.to_lowercase() {
self.update( self.update(
db, db,
PartialUser { PartialUser {
username: Some(new_username), username: Some(username),
..Default::default() ..Default::default()
}, },
vec![], vec![],
@@ -447,12 +434,12 @@ impl User {
discriminator: Some( discriminator: Some(
User::find_discriminator( User::find_discriminator(
db, db,
&new_username, &username,
Some((self.discriminator.to_string(), self.id.clone())), Some((self.discriminator.to_string(), self.id.clone())),
) )
.await?, .await?,
), ),
username: Some(new_username), username: Some(username),
..Default::default() ..Default::default()
}, },
vec![], vec![],
@@ -838,109 +825,3 @@ impl User {
badges badges
} }
} }
#[cfg(test)]
mod tests {
use crate::User;
#[test]
fn username_validation_blocked_names() {
let username_admin = "Admin";
let username_revolt = "Revolt";
let username_stoat = "Stoat";
let username_allowed = "Allowed";
assert!(User::validate_username(username_admin).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_allowed).is_ok());
}
#[test]
fn username_validation_blocked_patterns() {
let username_grave = "```_test";
let username_discord = "discord.gg_test";
let username_rvlt = "rvlt.gg_test";
let username_guilded = "guilded.gg_test";
let username_stt = "stt.gg_test";
let username_revolt = "revolt.chat_test";
let username_stoat = "stoat.chat_test";
let username_http = "http://_test";
let username_https = "https://_test";
assert!(User::validate_username(username_grave).is_err());
assert!(User::validate_username(username_discord).is_err());
assert!(User::validate_username(username_rvlt).is_err());
assert!(User::validate_username(username_guilded).is_err());
assert!(User::validate_username(username_stt).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_http).is_err());
assert!(User::validate_username(username_https).is_err());
}
#[async_std::test]
async fn username_sanitisation_clean() {
let username_clean = "Test";
let username_clean_sanitised = User::sanitise_username(username_clean).await;
assert!(username_clean_sanitised.is_ok());
assert_eq!(username_clean, username_clean_sanitised.unwrap());
}
#[async_std::test]
async fn username_sanitisation_homoglyphs() {
let username_homoglyphs = "𝔽𝕌Ňℕy";
let username_homoglyphs_sanitised =
User::sanitise_username(username_homoglyphs).await.unwrap();
assert_ne!(username_homoglyphs, username_homoglyphs_sanitised);
assert_eq!("funny", username_homoglyphs_sanitised);
}
#[async_std::test]
async fn username_sanitisation_padding() {
let username_padding = "a";
let username = User::sanitise_username(username_padding).await.unwrap();
assert_eq!("a_", username);
}
#[async_std::test]
async fn create_user() {
use revolt_result::Result;
database_test!(|db| async move {
let mut created_clean = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
assert_eq!("Test", created_clean.username);
created_clean
.update_username(&db, "Test2".to_string())
.await
.unwrap();
assert_eq!("Test2", created_clean.username);
let created_invalid_result: Result<_> =
User::create(&db, "stoat.chat".to_string(), None, None).await;
assert!(created_invalid_result.is_err());
let mut updated_invalid = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
let updated_invalid_update_result = updated_invalid
.update_username(&db, "http://test".to_string())
.await;
assert!(updated_invalid_update_result.is_err());
});
}
}
+4 -6
View File
@@ -105,11 +105,7 @@ pub async fn handle_ack_event(
if mentions_acked > 0 { if mentions_acked > 0 {
if let Err(err) = amqp if let Err(err) = amqp
.ack_notification_message( .ack_message(user.to_string(), channel.to_string(), id.to_owned())
user.to_string(),
channel.to_string(),
id.to_owned(),
)
.await .await
{ {
revolt_config::capture_error(&err); revolt_config::capture_error(&err);
@@ -196,7 +192,9 @@ pub async fn handle_ack_event(
.expect("Failed to fetch channel from db"); .expect("Failed to fetch channel from db");
if let TextChannel { server, .. } = channel { if let TextChannel { server, .. } = channel {
if let Err(err) = amqp.mass_mention_message_sent(server, mass_mentions).await { if let Err(err) =
amqp.mass_mention_message_sent(server, mass_mentions).await
{
revolt_config::capture_error(&err); revolt_config::capture_error(&err);
} }
} else { } else {
-77
View File
@@ -1,77 +0,0 @@
use redis_kiss::{get_connection, AsyncCommands};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{Result, ToRevoltError};
use crate::{events::client::EventV1, Channel, Database, Server, User, AMQP};
pub async fn ack_channel(user: &str, channel: &str, message: &str, amqp: &AMQP) -> Result<()> {
let mut redis = get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
let old: Option<String> = redis
.getset(format!("acker:{user}+{channel}"), message)
.await
.to_internal_error()?;
if old.is_none() || old.unwrap() == message {
amqp.process_ack(user, Some(channel), None)
.await
.to_internal_error()?;
}
Ok(())
}
pub async fn ack_server(user: &User, server: &Server, db: &Database, amqp: &AMQP) -> Result<()> {
let mut redis = get_connection()
.await
.map_err(|_| create_error!(InternalError))?;
let channels = db.fetch_channels(&server.channels).await?;
let query = crate::util::permissions::DatabasePermissionQuery::new(db, user).server(server);
for channel in channels {
let channel_id = channel.id();
let mut q = query.clone().channel(&channel);
if calculate_channel_permissions(&mut q)
.await
.has_channel_permission(ChannelPermission::ViewChannel)
{
let channel_last_msg = match &channel {
Channel::TextChannel {
last_message_id, ..
} => last_message_id,
_ => unreachable!(),
}
.clone();
if let Some(channel_last_msg) = channel_last_msg {
let old: Option<String> = redis
.getset(
format!("acker:{}+{}", user.id, channel_id),
&channel_last_msg,
)
.await
.to_internal_error()?;
if old.is_none() || old.unwrap() == channel_last_msg {
amqp.process_ack(&user.id, Some(channel_id), Some(&server.id))
.await
.to_internal_error()?;
EventV1::ChannelAck {
id: channel_id.to_string(),
user: user.id.clone(),
message_id: channel_last_msg,
}
.private(user.id.clone())
.await;
}
}
}
}
Ok(())
}
+5 -11
View File
@@ -190,7 +190,7 @@ impl From<crate::Channel> for Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice, voice,
slowmode, slowmode
} => Channel::TextChannel { } => Channel::TextChannel {
id, id,
server, server,
@@ -202,7 +202,7 @@ impl From<crate::Channel> for Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice: voice.map(|voice| voice.into()), voice: voice.map(|voice| voice.into()),
slowmode, slowmode
}, },
} }
} }
@@ -256,7 +256,7 @@ impl From<Channel> for crate::Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice, voice,
slowmode, slowmode
} => crate::Channel::TextChannel { } => crate::Channel::TextChannel {
id, id,
server, server,
@@ -268,7 +268,7 @@ impl From<Channel> for crate::Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice: voice.map(|voice| voice.into()), voice: voice.map(|voice| voice.into()),
slowmode, slowmode
}, },
} }
} }
@@ -307,7 +307,7 @@ impl From<PartialChannel> for crate::PartialChannel {
default_permissions: value.default_permissions, default_permissions: value.default_permissions,
last_message_id: value.last_message_id, last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into()), voice: value.voice.map(|voice| voice.into()),
slowmode: value.slowmode, slowmode: value.slowmode
} }
} }
} }
@@ -926,7 +926,6 @@ impl From<crate::Role> for Role {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -940,7 +939,6 @@ impl From<Role> for crate::Role {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -954,7 +952,6 @@ impl From<crate::PartialRole> for PartialRole {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -968,7 +965,6 @@ impl From<PartialRole> for crate::PartialRole {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -977,7 +973,6 @@ impl From<crate::FieldsRole> for FieldsRole {
fn from(value: crate::FieldsRole) -> Self { fn from(value: crate::FieldsRole) -> Self {
match value { match value {
crate::FieldsRole::Colour => FieldsRole::Colour, crate::FieldsRole::Colour => FieldsRole::Colour,
crate::FieldsRole::Icon => FieldsRole::Icon,
} }
} }
} }
@@ -986,7 +981,6 @@ impl From<FieldsRole> for crate::FieldsRole {
fn from(value: FieldsRole) -> Self { fn from(value: FieldsRole) -> Self {
match value { match value {
FieldsRole::Colour => crate::FieldsRole::Colour, FieldsRole::Colour => crate::FieldsRole::Colour,
FieldsRole::Icon => crate::FieldsRole::Icon,
} }
} }
} }
-1
View File
@@ -1,4 +1,3 @@
pub mod acker;
pub mod bridge; pub mod bridge;
pub mod bulk_permissions; pub mod bulk_permissions;
mod funcs; mod funcs;
+17 -17
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-files" name = "revolt-files"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -16,33 +16,33 @@ tracing = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
async-trait = { workspace = true } async-trait = { workspace = true }
ffprobe = { workspace = true } ffprobe = "0.4.0"
imagesize = { workspace = true } imagesize = "0.13.0"
tempfile = { workspace = true } tempfile = "3.12.0"
base64 = { workspace = true } base64 = "0.22.1"
aes-gcm = { workspace = true } aes-gcm = "0.10.3"
typenum = { workspace = true } typenum = "1.17.0"
aws-config = { workspace = true } aws-config = "1.5.5"
aws-sdk-s3 = { workspace = true, features = ["behavior-version-latest"] } aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
revolt-config = { workspace = true, features = [ revolt-config = { version = "0.12.0", path = "../config", features = [
"report-macros", "report-macros",
] } ] }
revolt-result = { workspace = true } revolt-result = { version = "0.12.0", path = "../result" }
# image processing # image processing
jxl-oxide = { workspace = true, features = ["image"] } jxl-oxide = { workspace = true }
image = { workspace = true } image = { workspace = true }
# svg rendering # svg rendering
usvg = { workspace = true } usvg = "0.44.0"
resvg = { workspace = true } resvg = "0.44.0"
tiny-skia = { workspace = true } tiny-skia = "0.11.4"
# encoding # encoding
webp = { workspace = true } webp = "0.3.0"
[dev-dependencies] [dev-dependencies]
uuid = { workspace = true, features = ["v4"] } uuid = { workspace = true }
+14 -14
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-models" name = "revolt-models"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -21,26 +21,26 @@ default = ["serde", "partials", "rocket"]
[dependencies] [dependencies]
# Core # Core
revolt-config = { workspace = true } revolt-config = { version = "0.12.0", path = "../config" }
revolt-permissions = { workspace = true } revolt-permissions = { version = "0.12.0", path = "../permissions" }
# Utility # Utility
regex = { workspace = true } regex = "1.11"
indexmap = { workspace = true } indexmap = "1.9.3"
once_cell = { workspace = true } once_cell = "1.17.1"
num_enum = { workspace = true } num_enum = "0.6.1"
# Rocket # Rocket
rocket = { workspace = true, optional = true } rocket = { optional = true, version = "0.5.0-rc.2", default-features = false }
# Serialisation # Serialisation
revolt_optional_struct = { workspace = true, optional = true } revolt_optional_struct = { version = "0.2.0", optional = true }
serde = { workspace = true, optional = true } serde = { version = "1", features = ["derive"], optional = true }
iso8601-timestamp = { workspace = true, features = ["schema", "bson"] } iso8601-timestamp = { version = "0.2.11", features = ["schema", "bson"] }
# Spec Generation # Spec Generation
schemars = { workspace = true, features = ["indexmap2"], optional = true } schemars = { version = "0.8.8", optional = true, features = ["indexmap1"] }
utoipa = { workspace = true, optional = true } utoipa = { version = "4.2.3", optional = true }
# Validation # Validation
validator = { workspace = true, features = ["derive"], optional = true } validator = { version = "0.16.0", optional = true, features = ["derive"] }
-6
View File
@@ -314,12 +314,6 @@ auto_derived!(
/// Only used when the user is the first one connected. /// Only used when the user is the first one connected.
pub recipients: Option<Vec<String>>, pub recipients: Option<Vec<String>>,
} }
pub struct ChannelSlowmode {
pub channel_id: String,
pub duration: u64,
pub retry_after: u64,
}
); );
impl Channel { impl Channel {
-18
View File
@@ -54,22 +54,4 @@ auto_derived!(
#[serde(default)] #[serde(default)]
pub nsfw: bool, pub nsfw: bool,
} }
/// Partial emoji representation
#[derive(Default)]
pub struct PartialEmoji {
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub name: Option<String>,
}
/// Edit emoji information
#[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditEmoji {
/// Emoji name
#[cfg_attr(
feature = "validator",
validate(length(min = 1, max = 32), regex = "RE_EMOJI")
)]
pub name: Option<String>,
}
); );
+1 -1
View File
@@ -51,7 +51,7 @@ auto_derived!(
width: usize, width: usize,
height: usize, height: usize,
thumbhash: Option<Vec<u8>>, thumbhash: Option<Vec<u8>>,
animated: Option<bool>, animated: bool,
}, },
/// File is a video with specific dimensions /// File is a video with specific dimensions
Video { width: usize, height: usize }, Video { width: usize, height: usize },
+2 -2
View File
@@ -261,7 +261,7 @@ auto_derived!(
pub nonce: Option<String>, pub nonce: Option<String>,
/// Message content to send /// Message content to send
#[cfg_attr(feature = "validator", validate(length(min = 0)))] #[cfg_attr(feature = "validator", validate(length(min = 0, max = 2000)))]
pub content: Option<String>, pub content: Option<String>,
/// Attachments to include in message /// Attachments to include in message
pub attachments: Option<Vec<String>>, pub attachments: Option<Vec<String>>,
@@ -345,7 +345,7 @@ auto_derived!(
#[cfg_attr(feature = "validator", derive(Validate))] #[cfg_attr(feature = "validator", derive(Validate))]
pub struct DataEditMessage { pub struct DataEditMessage {
/// New message content /// New message content
#[cfg_attr(feature = "validator", validate(length(min = 1)))] #[cfg_attr(feature = "validator", validate(length(min = 1, max = 2000)))]
pub content: Option<String>, pub content: Option<String>,
/// Embeds to include in the message /// Embeds to include in the message
#[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))] #[cfg_attr(feature = "validator", validate(length(min = 0, max = 10)))]
-9
View File
@@ -106,9 +106,6 @@ auto_derived_partial!(
/// Ranking of this role /// Ranking of this role
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub rank: i64, pub rank: i64,
/// Role icon
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
pub icon: Option<File>,
}, },
"PartialRole" "PartialRole"
); );
@@ -126,7 +123,6 @@ auto_derived!(
/// Optional fields on server object /// Optional fields on server object
pub enum FieldsRole { pub enum FieldsRole {
Colour, Colour,
Icon,
} }
/// Channel category /// Channel category
@@ -282,11 +278,6 @@ auto_derived!(
/// ///
/// **Removed** - no effect, use the edit server role positions route /// **Removed** - no effect, use the edit server role positions route
pub rank: Option<i64>, pub rank: Option<i64>,
/// Role icon
///
/// Provide an Autumn attachment Id.
#[cfg_attr(feature = "validator", validate(length(min = 1, max = 128)))]
pub icon: Option<String>,
/// Fields to remove from role object /// Fields to remove from role object
#[cfg_attr(feature = "serde", serde(default))] #[cfg_attr(feature = "serde", serde(default))]
pub remove: Vec<FieldsRole>, pub remove: Vec<FieldsRole>,
+2 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-parser" name = "revolt-parser"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"] authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
@@ -8,4 +8,4 @@ description = "Revolt Backend: Message Parser"
repository = "https://github.com/stoatchat/stoatchat" repository = "https://github.com/stoatchat/stoatchat"
[dependencies] [dependencies]
logos = { workspace = true } logos = { version = "0.15" }
+10 -10
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-permissions" name = "revolt-permissions"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -18,23 +18,23 @@ try-from-primitive = ["dep:num_enum"]
[dev-dependencies] [dev-dependencies]
# Async # Async
async-std = { workspace = true, features = ["attributes"] } async-std = { version = "1.8.0", features = ["attributes"] }
[dependencies] [dependencies]
# Core # Core
revolt-result = { workspace = true } revolt-result = { version = "0.12.0", path = "../result" }
# Utility # Utility
auto_ops = { workspace = true } auto_ops = "0.3.0"
once_cell = { workspace = true } once_cell = "1.17"
num_enum = { workspace = true, optional = true } num_enum = { version = "0.6.1", optional = true }
# Async # Async
async-trait = { workspace = true } async-trait = "0.1.51"
# Serialisation # Serialisation
serde = { workspace = true, optional = true } serde = { version = "1", features = ["derive"], optional = true }
bson = { workspace = true, optional = true } bson = { version = "2.1.0", optional = true }
# Spec Generation # Spec Generation
schemars = { workspace = true, optional = true } schemars = { version = "0.8.8", optional = true }
+7 -7
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-presence" name = "revolt-presence"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -14,16 +14,16 @@ redis-is-patched = []
[dev-dependencies] [dev-dependencies]
# Async # Async
async-std = { workspace = true, features = ["attributes"] } async-std = { version = "1.8.0", features = ["attributes"] }
# Config for loading Redis URI # Config for loading Redis URI
revolt-config = { workspace = true } revolt-config = { version = "0.12.0", path = "../config" }
[dependencies] [dependencies]
# Utility # Utility
log = { workspace = true } log = "0.4.17"
rand = { workspace = true } rand = "0.8.5"
once_cell = { workspace = true } once_cell = "1.17.1"
# Redis # Redis
redis-kiss = { workspace = true } redis-kiss = "0.1.4"
+12 -12
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-ratelimits" name = "revolt-ratelimits"
version = "0.13.7" version = "0.12.0"
edition = "2024" edition = "2024"
license = "MIT" license = "MIT"
authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"] authors = ["Zomatree <me@zomatree.live>", "Paul Makles <me@insrt.uk>"]
@@ -18,17 +18,17 @@ axum = ["dep:axum", "revolt-database/axum-impl"]
default = ["rocket", "axum"] default = ["rocket", "axum"]
[dependencies] [dependencies]
revolt-database = { workspace = true } revolt-database = { version = "0.12.0", path = "../database" }
revolt-result = { workspace = true } revolt-result = { version = "0.12.0", path = "../result" }
revolt-config = { workspace = true } revolt-config = { version = "0.12.0", path = "../config" }
rocket = { workspace = true, optional = true } rocket = { version = "0.5.1", optional = true }
revolt_rocket_okapi = { workspace = true, optional = true } revolt_rocket_okapi = { version = "0.10.0", optional = true }
axum = { workspace = true, optional = true, features = ["macros"] } axum = { version = "0.7.5", optional = true, features = ["macros"] }
serde = { workspace = true } serde = { version = "1", features = ["derive"] }
authifier = { workspace = true } authifier = { version = "1.0.16" }
dashmap = { workspace = true } dashmap = "5.2.0"
async-trait = { workspace = true } async-trait = "0.1.81"
log = { workspace = true } log = "0.4"
+3 -3
View File
@@ -1,12 +1,12 @@
use async_trait::async_trait; use async_trait::async_trait;
use log::info; use log::info;
use revolt_config::config;
use rocket::fairing::{Fairing, Info, Kind}; use rocket::fairing::{Fairing, Info, Kind};
use rocket::http::uri::Origin; use rocket::http::uri::Origin;
use rocket::http::{Method, Status}; use rocket::http::{Method, Status};
use rocket::request::{FromRequest, Outcome}; use rocket::request::{FromRequest, Outcome};
use rocket::serde::json::Json; use rocket::serde::json::Json;
use rocket::{Data, Request, Response, State}; use rocket::{Data, Request, Response, State};
use revolt_config::config;
use revolt_rocket_okapi::r#gen::OpenApiGenerator; use revolt_rocket_okapi::r#gen::OpenApiGenerator;
use revolt_rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput}; use revolt_rocket_okapi::request::{OpenApiFromRequest, RequestHeaderInput};
@@ -28,8 +28,8 @@ pub type RatelimitStorage = crate::ratelimiter::RatelimitStorage<RocketRequestKi
/// Find the remote IP of the client /// Find the remote IP of the client
fn to_ip(request: &'_ rocket::Request<'_>) -> String { fn to_ip(request: &'_ rocket::Request<'_>) -> String {
request request
.client_ip() .remote()
.map(|r| r.to_string()) .map(|x| x.ip().to_string())
.unwrap_or_default() .unwrap_or_default()
} }
+11 -11
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-result" name = "revolt-result"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -22,21 +22,21 @@ default = ["serde", "sentry"]
[dependencies] [dependencies]
# Serialisation # Serialisation
serde_json = { workspace = true, optional = true } serde_json = { version = "1", optional = true }
serde = { workspace = true, optional = true } serde = { version = "1", features = ["derive"], optional = true }
# Spec Generation # Spec Generation
schemars = { workspace = true, optional = true } schemars = { version = "0.8.8", optional = true }
utoipa = { workspace = true, optional = true } utoipa = { version = "4.2.3", optional = true }
# Rocket # Rocket
rocket = { workspace = true, optional = true } rocket = { optional = true, version = "0.5.0-rc.2", default-features = false }
revolt_rocket_okapi = { workspace = true, optional = true } revolt_rocket_okapi = { version = "0.10.0", optional = true }
revolt_okapi = { workspace = true, optional = true } revolt_okapi = { version = "0.9.1", optional = true }
# utilities # utilities
log = { workspace = true } log = "0.4"
# Axum # Axum
axum = { workspace = true, optional = true } axum = { version = "0.7.5", optional = true }
sentry = { workspace = true, optional = true } sentry = { version = "0.31.5", optional = true }
+4 -3
View File
@@ -24,7 +24,6 @@ impl IntoResponse for Error {
ErrorType::UnknownChannel => StatusCode::NOT_FOUND, ErrorType::UnknownChannel => StatusCode::NOT_FOUND,
ErrorType::UnknownMessage => StatusCode::NOT_FOUND, ErrorType::UnknownMessage => StatusCode::NOT_FOUND,
ErrorType::UnknownAttachment => StatusCode::BAD_REQUEST, ErrorType::UnknownAttachment => StatusCode::BAD_REQUEST,
ErrorType::CannotDeleteMessage => StatusCode::FORBIDDEN,
ErrorType::CannotEditMessage => StatusCode::FORBIDDEN, ErrorType::CannotEditMessage => StatusCode::FORBIDDEN,
ErrorType::CannotJoinCall => StatusCode::BAD_REQUEST, ErrorType::CannotJoinCall => StatusCode::BAD_REQUEST,
ErrorType::TooManyAttachments { .. } => StatusCode::BAD_REQUEST, ErrorType::TooManyAttachments { .. } => StatusCode::BAD_REQUEST,
@@ -37,7 +36,9 @@ impl IntoResponse for Error {
ErrorType::NotInGroup => StatusCode::NOT_FOUND, ErrorType::NotInGroup => StatusCode::NOT_FOUND,
ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST, ErrorType::AlreadyPinned => StatusCode::BAD_REQUEST,
ErrorType::NotPinned => StatusCode::BAD_REQUEST, ErrorType::NotPinned => StatusCode::BAD_REQUEST,
ErrorType::InSlowmode { retry_after: _ } => StatusCode::TOO_MANY_REQUESTS, ErrorType::InSlowmode {
retry_after: _,
} => StatusCode::TOO_MANY_REQUESTS,
ErrorType::CantCreateServers => StatusCode::FORBIDDEN, ErrorType::CantCreateServers => StatusCode::FORBIDDEN,
ErrorType::UnknownServer => StatusCode::NOT_FOUND, ErrorType::UnknownServer => StatusCode::NOT_FOUND,
@@ -77,7 +78,7 @@ impl IntoResponse for Error {
ErrorType::DuplicateNonce => StatusCode::CONFLICT, ErrorType::DuplicateNonce => StatusCode::CONFLICT,
ErrorType::VosoUnavailable => StatusCode::BAD_REQUEST, ErrorType::VosoUnavailable => StatusCode::BAD_REQUEST,
ErrorType::NotFound => StatusCode::NOT_FOUND, ErrorType::NotFound => StatusCode::NOT_FOUND,
ErrorType::NoEffect => StatusCode::BAD_REQUEST, ErrorType::NoEffect => StatusCode::OK,
ErrorType::FailedValidation { .. } => StatusCode::BAD_REQUEST, ErrorType::FailedValidation { .. } => StatusCode::BAD_REQUEST,
ErrorType::LiveKitUnavailable => StatusCode::BAD_REQUEST, ErrorType::LiveKitUnavailable => StatusCode::BAD_REQUEST,
ErrorType::NotConnected => StatusCode::BAD_REQUEST, ErrorType::NotConnected => StatusCode::BAD_REQUEST,
-1
View File
@@ -78,7 +78,6 @@ pub enum ErrorType {
UnknownChannel, UnknownChannel,
UnknownAttachment, UnknownAttachment,
UnknownMessage, UnknownMessage,
CannotDeleteMessage,
CannotEditMessage, CannotEditMessage,
CannotJoinCall, CannotJoinCall,
TooManyAttachments { TooManyAttachments {
+4 -3
View File
@@ -30,7 +30,6 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::UnknownChannel => Status::NotFound, ErrorType::UnknownChannel => Status::NotFound,
ErrorType::UnknownMessage => Status::NotFound, ErrorType::UnknownMessage => Status::NotFound,
ErrorType::UnknownAttachment => Status::BadRequest, ErrorType::UnknownAttachment => Status::BadRequest,
ErrorType::CannotDeleteMessage => Status::Forbidden,
ErrorType::CannotEditMessage => Status::Forbidden, ErrorType::CannotEditMessage => Status::Forbidden,
ErrorType::CannotJoinCall => Status::BadRequest, ErrorType::CannotJoinCall => Status::BadRequest,
ErrorType::TooManyAttachments { .. } => Status::BadRequest, ErrorType::TooManyAttachments { .. } => Status::BadRequest,
@@ -43,7 +42,9 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::NotInGroup => Status::NotFound, ErrorType::NotInGroup => Status::NotFound,
ErrorType::AlreadyPinned => Status::BadRequest, ErrorType::AlreadyPinned => Status::BadRequest,
ErrorType::NotPinned => Status::BadRequest, ErrorType::NotPinned => Status::BadRequest,
ErrorType::InSlowmode { retry_after: _ } => Status::TooManyRequests, ErrorType::InSlowmode {
retry_after: _,
} => Status::TooManyRequests,
ErrorType::InvalidFlagValue => Status::BadRequest, ErrorType::InvalidFlagValue => Status::BadRequest,
ErrorType::CantCreateServers => Status::Forbidden, ErrorType::CantCreateServers => Status::Forbidden,
@@ -83,7 +84,7 @@ impl<'r> Responder<'r, 'static> for Error {
ErrorType::NotAuthenticated => Status::Unauthorized, ErrorType::NotAuthenticated => Status::Unauthorized,
ErrorType::DuplicateNonce => Status::Conflict, ErrorType::DuplicateNonce => Status::Conflict,
ErrorType::NotFound => Status::NotFound, ErrorType::NotFound => Status::NotFound,
ErrorType::NoEffect => Status::BadRequest, ErrorType::NoEffect => Status::Ok,
ErrorType::FailedValidation { .. } => Status::BadRequest, ErrorType::FailedValidation { .. } => Status::BadRequest,
ErrorType::LiveKitUnavailable => Status::BadRequest, ErrorType::LiveKitUnavailable => Status::BadRequest,
ErrorType::NotAVoiceChannel => Status::BadRequest, ErrorType::NotAVoiceChannel => Status::BadRequest,
+7 -21
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-crond" name = "revolt-crond"
version = "0.13.7" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
edition = "2021" edition = "2021"
@@ -11,27 +11,13 @@ publish = false
[dependencies] [dependencies]
# Utility # Utility
log = { workspace = true } log = "0.4"
# Async # Async
tokio = { workspace = true } tokio = { version = "1" }
# Redis
redis-kiss = { workspace = true }
# RabbitMQ
lapin = { workspace = true }
futures-lite = { workspace = true }
# Processing
serde_json = { workspace = true }
revolt_optional_struct = { workspace = true }
serde = { workspace = true }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
# Core # Core
revolt-database = { workspace = true } revolt-database = { version = "0.12.0", path = "../../core/database" }
revolt-result = { workspace = true } revolt-result = { version = "0.12.0", path = "../../core/result" }
revolt-config = { workspace = true } revolt-config = { version = "0.12.0", path = "../../core/config" }
revolt-files = { workspace = true } revolt-files = { version = "0.12.0", path = "../../core/files" }
revolt-permissions = { workspace = true }
+3 -6
View File
@@ -1,7 +1,7 @@
use revolt_config::configure; use revolt_config::configure;
use revolt_database::{DatabaseInfo, AMQP}; use revolt_database::DatabaseInfo;
use revolt_result::Result; use revolt_result::Result;
use tasks::{acks, file_deletion, prune_dangling_files, prune_members}; use tasks::{file_deletion, prune_dangling_files, prune_members};
use tokio::try_join; use tokio::try_join;
pub mod tasks; pub mod tasks;
@@ -11,13 +11,10 @@ async fn main() -> Result<()> {
configure!(crond); configure!(crond);
let db = DatabaseInfo::Auto.connect().await.expect("database"); let db = DatabaseInfo::Auto.connect().await.expect("database");
let amqp = AMQP::new_auto().await;
try_join!( try_join!(
file_deletion::task(db.clone()), file_deletion::task(db.clone()),
prune_dangling_files::task(db.clone()), prune_dangling_files::task(db.clone()),
prune_members::task(db.clone()), prune_members::task(db.clone())
acks::task(db.clone(), amqp.clone()),
) )
.map(|_| ()) .map(|_| ())
} }
-164
View File
@@ -1,164 +0,0 @@
use futures_lite::stream::StreamExt;
use lapin::{
options::*,
types::FieldTable,
uri::{AMQPAuthority, AMQPQueryString, AMQPUri, AMQPUserInfo},
ConnectionBuilder, ConnectionProperties, ExchangeKind,
};
use log::{debug, info};
use redis_kiss::{get_connection, AsyncCommands, Conn as RedisConnection};
use revolt_config::config;
use revolt_database::{events::rabbit::AckEventPayload, Database, AMQP};
use revolt_result::{Result, ToRevoltError};
use serde_json;
pub async fn task(db: Database, amqp: AMQP) -> Result<()> {
let config = config().await;
let mut redis = get_connection()
.await
.expect("Failed to get redis connection");
let uri = AMQPUri {
scheme: lapin::uri::AMQPScheme::AMQP,
authority: AMQPAuthority {
userinfo: AMQPUserInfo {
username: config.rabbit.username,
password: config.rabbit.password,
},
host: config.rabbit.host,
port: config.rabbit.port,
},
vhost: "/".to_string(),
query: AMQPQueryString::default(),
};
let connection = ConnectionBuilder::new()
.expect("Builder")
.with_uri(uri)
.with_properties(ConnectionProperties::default())
.connect()
.await
.expect("Failed to connect to rabbitmq");
let reader_channel = connection
.create_channel()
.await
.expect("Failed to create channel");
reader_channel
.exchange_declare(
config.rabbit.default_exchange.clone().into(),
ExchangeKind::Topic,
ExchangeDeclareOptions {
durable: true,
..Default::default()
},
FieldTable::default(),
)
.await
.expect("Failed to declare exchange");
reader_channel
.queue_declare(
config.rabbit.queues.acks.clone().into(),
QueueDeclareOptions {
durable: true,
..Default::default()
},
FieldTable::default(),
)
.await
.expect("Failed to bind queue");
reader_channel
.queue_bind(
config.rabbit.queues.acks.clone().into(),
config.rabbit.default_exchange.into(),
config.rabbit.queues.acks.clone().into(),
QueueBindOptions::default(),
FieldTable::default(),
)
.await
.expect("Failed to bind channel");
let mut consumer = reader_channel
.basic_consume(
config.rabbit.queues.acks.into(),
"crond-ack-consumer".into(),
BasicConsumeOptions::default(),
FieldTable::default(),
)
.await
.expect("Failed to create consumer");
while let Some(delivery) = consumer.next().await {
if let Ok(delivery) = delivery {
let payload = serde_json::from_slice::<AckEventPayload>(&delivery.data);
if let Ok(payload) = payload {
debug!("Received ack event: {payload:?}");
if let Err(e) = process_channel_ack(
&db,
&amqp,
payload.user_id,
payload.channel_id.unwrap(),
&mut redis,
)
.await
{
revolt_config::capture_error(&e);
_ = delivery.reject(BasicRejectOptions { requeue: false }).await;
} else {
_ = delivery.ack(BasicAckOptions { multiple: false }).await;
}
} else {
revolt_config::capture_message(
format!("Failed to decode ack data: {:?}", delivery.data).as_str(),
revolt_config::Level::Error,
);
}
}
}
Ok(())
}
#[allow(clippy::disallowed_methods)]
async fn process_channel_ack(
db: &Database,
amqp: &AMQP,
user: String,
channel: String,
redis: &mut RedisConnection,
) -> Result<()> {
let message_id: Option<String> = redis
.get_del(format!("acker:{user}+{channel}"))
.await
.to_internal_error()?;
if let Some(message_id) = message_id {
let unread = db.fetch_unread(&user, &channel).await?;
let updated = db.acknowledge_message(&channel, &user, &message_id).await?;
info!("Set new state for ack: {}:{}:{}", channel, user, message_id);
if let (Some(before), Some(after)) = (unread, updated) {
let before_mentions = before.mentions.unwrap_or_default().len();
let after_mentions = after.mentions.unwrap_or_default().len();
if after_mentions < before_mentions {
if let Err(err) = amqp
.ack_notification_message(user.to_string(), channel.to_string(), message_id)
.await
{
revolt_config::capture_error(&err);
}
};
}
Ok(())
} else {
Err(message_id.to_internal_error().expect_err("no err"))
}
}
+13 -15
View File
@@ -11,24 +11,22 @@ pub async fn task(db: Database) -> Result<()> {
let files = db.fetch_deleted_attachments().await?; let files = db.fetch_deleted_attachments().await?;
for file in files { for file in files {
if let Some(hash) = &file.hash { let count = db
let count = db .count_file_hash_references(file.hash.as_ref().expect("no `hash` present"))
.count_file_hash_references(hash) .await?;
// No other files reference this file on disk anymore
if count <= 1 {
let file_hash = db
.fetch_attachment_hash(file.hash.as_ref().expect("no `hash` present"))
.await?; .await?;
// No other files reference this file on disk anymore // Delete from S3
if count <= 1 { delete_from_s3(&file_hash.bucket_id, &file_hash.path).await?;
let file_hash = db
.fetch_attachment_hash(hash)
.await?;
// Delete from S3 // Delete the hash
delete_from_s3(&file_hash.bucket_id, &file_hash.path).await?; db.delete_attachment_hash(&file_hash.id).await?;
info!("Deleted file hash {}", file_hash.id);
// Delete the hash
db.delete_attachment_hash(&file_hash.id).await?;
info!("Deleted file hash {}", file_hash.id);
}
} }
// Delete the file // Delete the file
-1
View File
@@ -1,4 +1,3 @@
pub mod acks;
pub mod file_deletion; pub mod file_deletion;
pub mod prune_dangling_files; pub mod prune_dangling_files;
pub mod prune_members; pub mod prune_members;
+33 -26
View File
@@ -1,40 +1,47 @@
[package] [package]
name = "revolt-pushd" name = "revolt-pushd"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
publish = false publish = false
[dependencies] [dependencies]
revolt-result = { workspace = true } revolt-result = { version = "0.12.0", path = "../../core/result" }
revolt-config = { workspace = true, features = ["report-macros", "anyhow"] } revolt-config = { version = "0.12.0", path = "../../core/config", features = [
revolt-database = { workspace = true } "report-macros",
revolt-models = { workspace = true, features = ["validator"] } "anyhow",
revolt-presence = { workspace = true, features = ["redis-is-patched"] } ] }
revolt-parser = { workspace = true } revolt-database = { version = "0.12.0", path = "../../core/database" }
revolt-models = { version = "0.12.0", path = "../../core/models", features = [
"validator",
] }
revolt-presence = { version = "0.12.0", path = "../../core/presence", features = [
"redis-is-patched",
] }
revolt-parser = { version = "0.12.0", path = "../../core/parser" }
anyhow = { workspace = true } anyhow = { version = "1.0.98" }
lapin = { workspace = true } amqprs = { version = "1.7.0" }
fcm_v1 = { workspace = true } fcm_v1 = "0.3.0"
web-push = { workspace = true } web-push = "0.10.0"
isahc = { workspace = true, features = ["json"], optional = true } isahc = { optional = true, version = "1.7", features = ["json"] }
revolt_a2 = { workspace = true, features = ["ring"] } revolt_a2 = { version = "0.10", default-features = false, features = ["ring"] }
redis-kiss = { workspace = true } redis-kiss = "0.1.4"
tokio = { workspace = true } tokio = "1.39.2"
async-trait = { workspace = true } async-trait = "0.1.81"
ulid = { workspace = true } ulid = "1.0.0"
authifier = { workspace = true } authifier = "1.0.16"
log = { workspace = true } log = "0.4.11"
pretty_env_logger = { workspace = true } pretty_env_logger = "0.4.0"
regex = { workspace = true } regex = "1.12.3"
#serialization #serialization
serde_json = { workspace = true } serde_json = "1"
revolt_optional_struct = { workspace = true } revolt_optional_struct = "0.2.0"
serde = { workspace = true } serde = { version = "1", features = ["derive"] }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] } iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] }
base64 = { workspace = true } base64 = "0.22.1"
@@ -1,69 +1,96 @@
use std::sync::Arc; use crate::consumers::inbound::internal::*;
use amqprs::{
use crate::utils::Consumer; channel::{BasicPublishArguments, Channel},
use anyhow::Result; connection::Connection,
consumer::AsyncConsumer,
BasicProperties, Deliver,
};
use async_trait::async_trait; use async_trait::async_trait;
use lapin::{message::Delivery, Channel, Connection};
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
#[derive(Clone)]
#[allow(unused)]
pub struct AckConsumer { pub struct AckConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database, authifier_db: authifier::Database,
connection: Arc<Connection>, conn: Option<Connection>,
channel: Arc<Channel>, channel: Option<Channel>,
} }
#[async_trait] impl Channeled for AckConsumer {
impl Consumer for AckConsumer { fn get_connection(&self) -> Option<&Connection> {
async fn create( if self.conn.is_none() {
db: Database, None
authifier_db: authifier::Database, } else {
connection: Arc<Connection>, Some(self.conn.as_ref().unwrap())
channel: Arc<Channel>,
) -> Self {
Self {
db,
authifier_db,
connection,
channel,
} }
} }
fn channel(&self) -> &Arc<Channel> { fn get_channel(&self) -> Option<&Channel> {
&self.channel if self.channel.is_none() {
None
} else {
Some(self.channel.as_ref().unwrap())
}
} }
fn set_connection(&mut self, conn: Connection) {
self.conn = Some(conn);
}
fn set_channel(&mut self, channel: Channel) {
self.channel = Some(channel)
}
}
impl AckConsumer {
pub fn new(db: Database, authifier_db: authifier::Database) -> AckConsumer {
AckConsumer {
db,
authifier_db,
conn: None,
channel: None,
}
}
}
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for AckConsumer {
/// This consumer processes all acks the platform receives, and sends relevant badge updates to apple platforms. /// This consumer processes all acks the platform receives, and sends relevant badge updates to apple platforms.
async fn consume(&self, delivery: Delivery) -> Result<()> { async fn consume(
let payload: AckPayload = serde_json::from_slice(&delivery.data)?; &mut self,
channel: &Channel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
let content = String::from_utf8(content).unwrap();
let payload: AckPayload = serde_json::from_str(content.as_str()).unwrap();
// Step 1: fetch unreads and don't continue if there's no unreads // Step 1: fetch unreads and don't continue if there's no unreads
// #[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_methods)]
let unreads = self.db.fetch_unread_mentions(&payload.user_id).await;
debug!("Processing unreads for {:}", &payload.user_id); debug!("Processing unreads for {:}", &payload.user_id);
let unreads = if let Ok(u) = self.db.fetch_unread_mentions(&payload.user_id).await { if let Ok(u) = &unreads {
if u.is_empty() { if u.is_empty() {
debug!( debug!(
"Discarding unread task (no mentions found) for {:}", "Discarding unread task (no mentions found) for {:}",
&payload.user_id &payload.user_id
); );
return Ok(()); return;
}; }
u
} else { } else {
return Ok(()); return;
}; }
if let Ok(sessions) = self.authifier_db.find_sessions(&payload.user_id).await { if let Ok(sessions) = self.authifier_db.find_sessions(&payload.user_id).await {
let config = revolt_config::config().await; let config = revolt_config::config().await;
// Step 2: find any apple sessions, since we don't need to calculate this for anything else. // Step 2: find any apple sessions, since we don't need to calculate this for anything else.
// If there's no apple sessions, we can return early // If there's no apple sessions, we can return early
let mut apple_sessions = sessions let apple_sessions: Vec<&authifier::models::Session> = sessions
.into_iter() .iter()
.filter(|session| { .filter(|session| {
if let Some(sub) = &session.subscription { if let Some(sub) = &session.subscription {
sub.endpoint == "apn" sub.endpoint == "apn"
@@ -71,19 +98,19 @@ impl Consumer for AckConsumer {
false false
} }
}) })
.peekable(); .collect();
if apple_sessions.peek().is_none() { if apple_sessions.is_empty() {
debug!( debug!(
"Discarding unread task (no apn sessions found) for {:}", "Discarding unread task (no apn sessions found) for {:}",
&payload.user_id &payload.user_id
); );
return Ok(()); return;
} }
// Step 3: calculate the actual mention count, since we have to send it out // Step 3: calculate the actual mention count, since we have to send it out
let mut mention_count = 0; let mut mention_count = 0;
for u in &unreads { for u in &unreads.unwrap() {
mention_count += u.mentions.as_ref().unwrap().len() mention_count += u.mentions.as_ref().unwrap().len()
} }
@@ -96,22 +123,26 @@ impl Consumer for AckConsumer {
token: session.subscription.as_ref().unwrap().auth.clone(), token: session.subscription.as_ref().unwrap().auth.clone(),
extras: Default::default(), extras: Default::default(),
}; };
let payload = serde_json::to_string(&service_payload)?; let raw_service_payload = serde_json::to_string(&service_payload);
log::debug!( if let Ok(p) = raw_service_payload {
"Publishing ack to apn session {}", let args = BasicPublishArguments::new(
session.subscription.as_ref().unwrap().auth config.pushd.exchange.as_str(),
); config.pushd.apn.queue.as_str(),
)
.finish();
self.publish_message( log::debug!(
payload.as_bytes(), "Publishing ack to apn session {}",
&config.pushd.exchange, session.subscription.as_ref().unwrap().auth
&config.pushd.apn.queue, );
)
.await?; publish_message(self, p.into(), args).await;
} else {
log::warn!("Failed to serialize ack badge update payload!");
revolt_config::capture_error(&raw_service_payload.unwrap_err());
}
} }
} }
Ok(())
} }
} }
@@ -1,44 +1,70 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use crate::utils::Consumer; use crate::consumers::inbound::internal::*;
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
consumer::AsyncConsumer,
BasicProperties, Deliver,
};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use lapin::{message::Delivery, Channel, Connection};
use log::debug; use log::debug;
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
#[derive(Clone)]
#[allow(unused)]
pub struct DmCallConsumer { pub struct DmCallConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database, authifier_db: authifier::Database,
connection: Arc<Connection>, conn: Option<Connection>,
channel: Arc<Channel>, channel: Option<Channel>,
} }
#[async_trait] impl Channeled for DmCallConsumer {
impl Consumer for DmCallConsumer { fn get_connection(&self) -> Option<&Connection> {
async fn create( if self.conn.is_none() {
db: Database, None
authifier_db: authifier::Database, } else {
connection: Arc<Connection>, Some(self.conn.as_ref().unwrap())
channel: Arc<Channel>,
) -> Self {
Self {
db,
authifier_db,
connection,
channel,
} }
} }
fn channel(&self) -> &Arc<Channel> { fn get_channel(&self) -> Option<&Channel> {
&self.channel if self.channel.is_none() {
None
} else {
Some(self.channel.as_ref().unwrap())
}
} }
/// This consumer handles delegating messages into their respective platform queues. fn set_connection(&mut self, conn: Connection) {
async fn consume(&self, delivery: Delivery) -> Result<()> { self.conn = Some(conn);
let _p: InternalDmCallPayload = serde_json::from_slice(&delivery.data)?; }
fn set_channel(&mut self, channel: Channel) {
self.channel = Some(channel)
}
}
impl DmCallConsumer {
pub fn new(db: Database, authifier_db: authifier::Database) -> DmCallConsumer {
DmCallConsumer {
db,
authifier_db,
conn: None,
channel: None,
}
}
async fn consume_event(
&mut self,
_channel: &Channel,
_deliver: Deliver,
_basic_properties: BasicProperties,
content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let _p: InternalDmCallPayload = serde_json::from_str(content.as_str())?;
let payload = _p.payload; let payload = _p.payload;
debug!("Received dm call start/stop event"); debug!("Received dm call start/stop event");
@@ -81,27 +107,36 @@ impl Consumer for DmCallConsumer {
extras: HashMap::new(), extras: HashMap::new(),
}; };
let routing_key = match sub.endpoint.as_str() { let args: BasicPublishArguments;
"apn" => &config.pushd.apn.queue,
"fcm" => &config.pushd.fcm.queue,
endpoint => {
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), endpoint.to_string());
&config.pushd.vapid.queue if sub.endpoint == "apn" {
} args = BasicPublishArguments::new(
}; config.pushd.exchange.as_str(),
config.pushd.apn.queue.as_str(),
)
.finish();
} else if sub.endpoint == "fcm" {
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.fcm.queue.as_str(),
)
.finish();
} else {
// web push (vapid)
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());
}
let payload = serde_json::to_string(&sendable)?; let payload = serde_json::to_string(&sendable)?;
self.publish_message( publish_message(self, payload.into(), args).await;
payload.as_bytes(),
&config.pushd.exchange,
routing_key,
)
.await?;
} }
} }
} }
@@ -110,3 +145,24 @@ impl Consumer for DmCallConsumer {
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for DmCallConsumer {
/// This consumer handles delegating messages into their respective platform queues.
async fn consume(
&mut self,
channel: &Channel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
warn!("Failed to process dm call start/stop event: {err:?}");
}
}
}
@@ -1,44 +1,70 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use crate::utils::Consumer; use crate::consumers::inbound::internal::*;
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
consumer::AsyncConsumer,
BasicProperties, Deliver,
};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use lapin::{message::Delivery, Channel, Connection};
use log::debug; use log::debug;
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
#[derive(Clone)]
#[allow(unused)]
pub struct FRAcceptedConsumer { pub struct FRAcceptedConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database, authifier_db: authifier::Database,
connection: Arc<Connection>, conn: Option<Connection>,
channel: Arc<Channel>, channel: Option<Channel>,
} }
#[async_trait] impl Channeled for FRAcceptedConsumer {
impl Consumer for FRAcceptedConsumer { fn get_connection(&self) -> Option<&Connection> {
async fn create( if self.conn.is_none() {
db: Database, None
authifier_db: authifier::Database, } else {
connection: Arc<Connection>, Some(self.conn.as_ref().unwrap())
channel: Arc<Channel>,
) -> Self {
Self {
db,
authifier_db,
connection,
channel,
} }
} }
fn channel(&self) -> &Arc<Channel> { fn get_channel(&self) -> Option<&Channel> {
&self.channel if self.channel.is_none() {
None
} else {
Some(self.channel.as_ref().unwrap())
}
} }
/// This consumer handles delegating messages into their respective platform queues. fn set_connection(&mut self, conn: Connection) {
async fn consume(&self, delivery: Delivery) -> Result<()> { self.conn = Some(conn);
let payload: FRAcceptedPayload = serde_json::from_slice(&delivery.data)?; }
fn set_channel(&mut self, channel: Channel) {
self.channel = Some(channel)
}
}
impl FRAcceptedConsumer {
pub fn new(db: Database, authifier_db: authifier::Database) -> FRAcceptedConsumer {
FRAcceptedConsumer {
db,
authifier_db,
conn: None,
channel: None,
}
}
async fn consume_event(
&mut self,
_channel: &Channel,
_deliver: Deliver,
_basic_properties: BasicProperties,
content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let payload: FRAcceptedPayload = serde_json::from_str(content.as_str())?;
debug!("Received FR accept event"); debug!("Received FR accept event");
@@ -54,23 +80,36 @@ impl Consumer for FRAcceptedConsumer {
extras: HashMap::new(), extras: HashMap::new(),
}; };
let routing_key = match sub.endpoint.as_str() { let args: BasicPublishArguments;
"apn" => &config.pushd.apn.queue,
"fcm" => &config.pushd.fcm.queue,
endpoint => {
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), endpoint.to_string());
&config.pushd.vapid.queue if sub.endpoint == "apn" {
} args = BasicPublishArguments::new(
}; config.pushd.exchange.as_str(),
config.pushd.apn.queue.as_str(),
)
.finish();
} else if sub.endpoint == "fcm" {
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.fcm.queue.as_str(),
)
.finish();
} else {
// web push (vapid)
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());
}
let payload = serde_json::to_string(&sendable)?; let payload = serde_json::to_string(&sendable)?;
self.publish_message(payload.as_bytes(), &config.pushd.exchange, routing_key) publish_message(self, payload.into(), args).await;
.await?;
} }
} }
} }
@@ -78,3 +117,24 @@ impl Consumer for FRAcceptedConsumer {
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for FRAcceptedConsumer {
/// This consumer handles delegating messages into their respective platform queues.
async fn consume(
&mut self,
channel: &Channel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process friend request accepted event: {err:?}");
}
}
}
@@ -1,44 +1,70 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use crate::utils::Consumer; use crate::consumers::inbound::internal::*;
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
consumer::AsyncConsumer,
BasicProperties, Deliver,
};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use lapin::{message::Delivery, Channel, Connection};
use log::debug; use log::debug;
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
#[derive(Clone)]
#[allow(unused)]
pub struct FRReceivedConsumer { pub struct FRReceivedConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database, authifier_db: authifier::Database,
connection: Arc<Connection>, conn: Option<Connection>,
channel: Arc<Channel>, channel: Option<Channel>,
} }
#[async_trait] impl Channeled for FRReceivedConsumer {
impl Consumer for FRReceivedConsumer { fn get_connection(&self) -> Option<&Connection> {
async fn create( if self.conn.is_none() {
db: Database, None
authifier_db: authifier::Database, } else {
connection: Arc<Connection>, Some(self.conn.as_ref().unwrap())
channel: Arc<Channel>,
) -> Self {
Self {
db,
authifier_db,
connection,
channel,
} }
} }
fn channel(&self) -> &Arc<Channel> { fn get_channel(&self) -> Option<&Channel> {
&self.channel if self.channel.is_none() {
None
} else {
Some(self.channel.as_ref().unwrap())
}
} }
/// This consumer handles delegating messages into their respective platform queues. fn set_connection(&mut self, conn: Connection) {
async fn consume(&self, delivery: Delivery) -> Result<()> { self.conn = Some(conn);
let payload: FRReceivedPayload = serde_json::from_slice(&delivery.data)?; }
fn set_channel(&mut self, channel: Channel) {
self.channel = Some(channel)
}
}
impl FRReceivedConsumer {
pub fn new(db: Database, authifier_db: authifier::Database) -> FRReceivedConsumer {
FRReceivedConsumer {
db,
authifier_db,
conn: None,
channel: None,
}
}
async fn consume_event(
&mut self,
_channel: &Channel,
_deliver: Deliver,
_basic_properties: BasicProperties,
content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let payload: FRReceivedPayload = serde_json::from_str(content.as_str())?;
debug!("Received FR received event"); debug!("Received FR received event");
@@ -54,23 +80,36 @@ impl Consumer for FRReceivedConsumer {
extras: HashMap::new(), extras: HashMap::new(),
}; };
let routing_key = match sub.endpoint.as_str() { let args: BasicPublishArguments;
"apn" => &config.pushd.apn.queue,
"fcm" => &config.pushd.fcm.queue,
endpoint => {
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), endpoint.to_string());
&config.pushd.vapid.queue if sub.endpoint == "apn" {
} args = BasicPublishArguments::new(
}; config.pushd.exchange.as_str(),
config.pushd.apn.queue.as_str(),
)
.finish();
} else if sub.endpoint == "fcm" {
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.fcm.queue.as_str(),
)
.finish();
} else {
// web push (vapid)
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());
}
let payload = serde_json::to_string(&sendable)?; let payload = serde_json::to_string(&sendable)?;
self.publish_message(payload.as_bytes(), &config.pushd.exchange, routing_key) publish_message(self, payload.into(), args).await;
.await?;
} }
} }
} }
@@ -78,3 +117,24 @@ impl Consumer for FRReceivedConsumer {
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for FRReceivedConsumer {
/// This consumer handles delegating messages into their respective platform queues.
async fn consume(
&mut self,
channel: &Channel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process friend request received event: {err:?}");
}
}
}
@@ -1,44 +1,70 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use crate::utils::Consumer; use crate::consumers::inbound::internal::*;
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
consumer::AsyncConsumer,
BasicProperties, Deliver,
};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use lapin::{message::Delivery, Channel, Connection};
use log::debug; use log::debug;
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
#[derive(Clone)]
#[allow(unused)]
pub struct GenericConsumer { pub struct GenericConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database, authifier_db: authifier::Database,
connection: Arc<Connection>, conn: Option<Connection>,
channel: Arc<Channel>, channel: Option<Channel>,
} }
#[async_trait] impl Channeled for GenericConsumer {
impl Consumer for GenericConsumer { fn get_connection(&self) -> Option<&Connection> {
async fn create( if self.conn.is_none() {
db: Database, None
authifier_db: authifier::Database, } else {
connection: Arc<Connection>, Some(self.conn.as_ref().unwrap())
channel: Arc<Channel>,
) -> Self {
Self {
db,
authifier_db,
connection,
channel,
} }
} }
fn channel(&self) -> &Arc<Channel> { fn get_channel(&self) -> Option<&Channel> {
&self.channel if self.channel.is_none() {
None
} else {
Some(self.channel.as_ref().unwrap())
}
} }
/// This consumer handles delegating messages into their respective platform queues. fn set_connection(&mut self, conn: Connection) {
async fn consume(&self, delivery: Delivery) -> Result<()> { self.conn = Some(conn);
let payload: MessageSentPayload = serde_json::from_slice(&delivery.data)?; }
fn set_channel(&mut self, channel: Channel) {
self.channel = Some(channel)
}
}
impl GenericConsumer {
pub fn new(db: Database, authifier_db: authifier::Database) -> GenericConsumer {
GenericConsumer {
db,
authifier_db,
conn: None,
channel: None,
}
}
async fn consume_event(
&mut self,
_channel: &Channel,
_deliver: Deliver,
_basic_properties: BasicProperties,
content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let payload: MessageSentPayload = serde_json::from_str(content.as_str())?;
debug!("Received message event on origin"); debug!("Received message event on origin");
@@ -60,23 +86,36 @@ impl Consumer for GenericConsumer {
extras: HashMap::new(), extras: HashMap::new(),
}; };
let routing_key = match sub.endpoint.as_str() { let args: BasicPublishArguments;
"apn" => &config.pushd.apn.queue,
"fcm" => &config.pushd.fcm.queue,
endpoint => {
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), endpoint.to_string());
&config.pushd.vapid.queue if sub.endpoint == "apn" {
} args = BasicPublishArguments::new(
}; config.pushd.exchange.as_str(),
config.pushd.apn.queue.as_str(),
)
.finish();
} else if sub.endpoint == "fcm" {
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.fcm.queue.as_str(),
)
.finish();
} else {
// web push (vapid)
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());
}
let payload = serde_json::to_string(&sendable)?; let payload = serde_json::to_string(&sendable)?;
self.publish_message(payload.as_bytes(), &config.pushd.exchange, routing_key) publish_message(self, payload.into(), args).await;
.await?;
} }
} }
} }
@@ -84,3 +123,24 @@ impl Consumer for GenericConsumer {
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for GenericConsumer {
/// This consumer handles delegating messages into their respective platform queues.
async fn consume(
&mut self,
channel: &Channel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process generic event: {err:?}");
}
}
}
@@ -0,0 +1,53 @@
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::{Connection, OpenConnectionArguments},
BasicProperties,
};
use log::{debug, warn};
pub(crate) trait Channeled {
#[allow(unused)]
fn get_connection(&self) -> Option<&Connection>;
fn get_channel(&self) -> Option<&Channel>;
fn set_connection(&mut self, conn: Connection);
fn set_channel(&mut self, channel: Channel);
}
pub(crate) async fn make_channel<T: Channeled>(consumer: &mut T) {
let config = revolt_config::config().await;
let args = OpenConnectionArguments::new(
&config.rabbit.host,
config.rabbit.port,
&config.rabbit.username,
&config.rabbit.password,
);
let conn = amqprs::connection::Connection::open(&args).await.unwrap();
let channel = conn.open_channel(None).await.unwrap();
consumer.set_connection(conn);
consumer.set_channel(channel);
}
pub(crate) async fn publish_message<T: Channeled>(
consumer: &mut T,
payload: Vec<u8>,
args: BasicPublishArguments,
) {
let routing_key = &args.routing_key.clone();
let mut channel = consumer.get_channel();
if channel.is_none() {
make_channel(consumer).await;
channel = consumer.get_channel();
}
if let Some(chnl) = channel {
chnl.basic_publish(BasicProperties::default(), payload.clone(), args.clone())
.await
.unwrap();
debug!("Sent message to queue for target {}", routing_key);
} else {
warn!("Failed to unwrap channel (including attempt to make a channel)!")
}
}
@@ -1,13 +1,17 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet},
hash::RandomState, hash::RandomState,
sync::Arc,
}; };
use crate::utils::{render_notification_content, Consumer}; use crate::{consumers::inbound::internal::*, utils};
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
consumer::AsyncConsumer,
BasicProperties, Deliver,
};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use lapin::{message::Delivery, Channel, Connection};
use revolt_database::{ use revolt_database::{
events::rabbit::*, util::bulk_permissions::BulkDatabasePermissionQuery, Database, Member, events::rabbit::*, util::bulk_permissions::BulkDatabasePermissionQuery, Database, Member,
MessageFlagsValue, MessageFlagsValue,
@@ -15,18 +19,52 @@ use revolt_database::{
use revolt_models::v0::{MessageFlags, PushNotification}; use revolt_models::v0::{MessageFlags, PushNotification};
use revolt_result::ToRevoltError; use revolt_result::ToRevoltError;
#[derive(Clone)]
#[allow(unused)]
pub struct MassMessageConsumer { pub struct MassMessageConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database, authifier_db: authifier::Database,
connection: Arc<Connection>, conn: Option<Connection>,
channel: Arc<Channel>, channel: Option<Channel>,
}
impl Channeled for MassMessageConsumer {
fn get_connection(&self) -> Option<&Connection> {
if self.conn.is_none() {
None
} else {
Some(self.conn.as_ref().unwrap())
}
}
fn get_channel(&self) -> Option<&Channel> {
if self.channel.is_none() {
None
} else {
Some(self.channel.as_ref().unwrap())
}
}
fn set_connection(&mut self, conn: Connection) {
self.conn = Some(conn);
}
fn set_channel(&mut self, channel: Channel) {
self.channel = Some(channel)
}
} }
impl MassMessageConsumer { impl MassMessageConsumer {
pub fn new(db: Database, authifier_db: authifier::Database) -> MassMessageConsumer {
MassMessageConsumer {
db,
authifier_db,
conn: None,
channel: None,
}
}
async fn fire_notification_for_users( async fn fire_notification_for_users(
&self, &mut self,
push: &PushNotification, push: &PushNotification,
users: &[String], users: &[String],
) -> Result<()> { ) -> Result<()> {
@@ -46,58 +84,56 @@ impl MassMessageConsumer {
extras: HashMap::new(), extras: HashMap::new(),
}; };
let routing_key = match sub.endpoint.as_str() { let args: BasicPublishArguments;
"apn" => &config.pushd.apn.queue,
"fcm" => &config.pushd.fcm.queue,
endpoint => {
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), endpoint.to_string());
&config.pushd.vapid.queue if sub.endpoint == "apn" {
} args = BasicPublishArguments::new(
}; config.pushd.exchange.as_str(),
config.pushd.apn.queue.as_str(),
)
.finish();
} else if sub.endpoint == "fcm" {
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.fcm.queue.as_str(),
)
.finish();
} else {
// web push (vapid)
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());
}
let payload = serde_json::to_string(&sendable)?; let payload = serde_json::to_string(&sendable)?;
self.publish_message(payload.as_bytes(), &config.pushd.exchange, routing_key) publish_message(self, payload.into(), args).await;
.await?;
} }
} }
} }
Ok(()) Ok(())
} }
}
#[async_trait] async fn consume_event(
impl Consumer for MassMessageConsumer { &mut self,
async fn create( _channel: &Channel,
db: Database, _deliver: Deliver,
authifier_db: authifier::Database, _basic_properties: BasicProperties,
connection: Arc<Connection>, content: Vec<u8>,
channel: Arc<Channel>, ) -> Result<()> {
) -> Self {
Self {
db,
authifier_db,
connection,
channel,
}
}
fn channel(&self) -> &Arc<Channel> {
&self.channel
}
/// This consumer handles adding mentions for all the users affected by a mass mention ping, and then sends out push notifications.
async fn consume(&self, delivery: Delivery) -> Result<()> {
let mut payload: MassMessageSentPayload = serde_json::from_slice(&delivery.data)?;
let config = revolt_config::config().await; let config = revolt_config::config().await;
let content = String::from_utf8(content)?;
let mut payload: MassMessageSentPayload = serde_json::from_str(content.as_str())?;
for push in payload.notifications.iter_mut() { for push in payload.notifications.iter_mut() {
if let Ok(body) = render_notification_content(push, &self.db) if let Ok(body) = utils::render_notification_content(push, &self.db)
.await .await
.to_internal_error() .to_internal_error()
{ {
@@ -244,3 +280,24 @@ impl Consumer for MassMessageConsumer {
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for MassMessageConsumer {
/// This consumer handles adding mentions for all the users affected by a mass mention ping, and then sends out push notifications
async fn consume(
&mut self,
channel: &Channel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process mass message event: {err:?}");
}
}
}
@@ -1,46 +1,76 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use crate::utils::{render_notification_content, Consumer}; use crate::{consumers::inbound::internal::*, utils};
use amqprs::{
channel::{BasicPublishArguments, Channel},
connection::Connection,
consumer::AsyncConsumer,
BasicProperties, Deliver,
};
use anyhow::Result; use anyhow::Result;
use async_trait::async_trait; use async_trait::async_trait;
use lapin::{message::Delivery, Channel, Connection};
use log::debug; use log::debug;
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
use revolt_result::ToRevoltError;
#[derive(Clone)]
#[allow(unused)]
pub struct MessageConsumer { pub struct MessageConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database, authifier_db: authifier::Database,
connection: Arc<Connection>, conn: Option<Connection>,
channel: Arc<Channel>, channel: Option<Channel>,
} }
#[async_trait] impl Channeled for MessageConsumer {
impl Consumer for MessageConsumer { fn get_connection(&self) -> Option<&Connection> {
async fn create( if self.conn.is_none() {
db: Database, None
authifier_db: authifier::Database, } else {
connection: Arc<Connection>, Some(self.conn.as_ref().unwrap())
channel: Arc<Channel>,
) -> Self {
Self {
db,
authifier_db,
connection,
channel,
} }
} }
fn channel(&self) -> &Arc<Channel> { fn get_channel(&self) -> Option<&Channel> {
&self.channel if self.channel.is_none() {
None
} else {
Some(self.channel.as_ref().unwrap())
}
} }
/// This consumer handles delegating messages into their respective platform queues. fn set_connection(&mut self, conn: Connection) {
async fn consume(&self, delivery: Delivery) -> Result<()> { self.conn = Some(conn);
let mut payload: MessageSentPayload = serde_json::from_slice(&delivery.data)?; }
if let Ok(body) = render_notification_content(&payload.notification, &self.db).await { fn set_channel(&mut self, channel: Channel) {
self.channel = Some(channel)
}
}
impl MessageConsumer {
pub fn new(db: Database, authifier_db: authifier::Database) -> MessageConsumer {
MessageConsumer {
db,
authifier_db,
conn: None,
channel: None,
}
}
async fn consume_event(
&mut self,
_channel: &Channel,
_deliver: Deliver,
_basic_properties: BasicProperties,
content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
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.raw_body = Some(payload.notification.body);
payload.notification.body = body; payload.notification.body = body;
} }
@@ -65,22 +95,36 @@ impl Consumer for MessageConsumer {
extras: HashMap::new(), extras: HashMap::new(),
}; };
let routing_key = match sub.endpoint.as_str() { let args: BasicPublishArguments;
"apn" => &config.pushd.apn.queue,
"fcm" => &config.pushd.fcm.queue,
endpoint => {
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), endpoint.to_string());
&config.pushd.vapid.queue if sub.endpoint == "apn" {
} args = BasicPublishArguments::new(
}; config.pushd.exchange.as_str(),
config.pushd.apn.queue.as_str(),
)
.finish();
} else if sub.endpoint == "fcm" {
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.fcm.queue.as_str(),
)
.finish();
} else {
// web push (vapid)
args = BasicPublishArguments::new(
config.pushd.exchange.as_str(),
config.pushd.vapid.queue.as_str(),
)
.finish();
sendable.extras.insert("p256dh".to_string(), sub.p256dh);
sendable
.extras
.insert("endpoint".to_string(), sub.endpoint.clone());
}
let payload = serde_json::to_string(&sendable)?; let payload = serde_json::to_string(&sendable)?;
self.publish_message(payload.as_bytes(), &config.pushd.exchange, routing_key)
.await?; publish_message(self, payload.into(), args).await;
} }
} }
} }
@@ -88,3 +132,24 @@ impl Consumer for MessageConsumer {
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for MessageConsumer {
/// This consumer handles delegating messages into their respective platform queues.
async fn consume(
&mut self,
channel: &Channel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process message event: {err:?}");
}
}
}
@@ -3,5 +3,6 @@ pub mod dm_call;
pub mod fr_accepted; pub mod fr_accepted;
pub mod fr_received; pub mod fr_received;
pub mod generic; pub mod generic;
mod internal;
pub mod mass_mention; pub mod mass_mention;
pub mod message; pub mod message;
@@ -1,13 +1,12 @@
use std::{borrow::Cow, collections::BTreeMap, io::Cursor, sync::Arc}; use std::{borrow::Cow, collections::BTreeMap, io::Cursor};
use crate::utils::Consumer; use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProperties, Deliver};
use anyhow::Result; use anyhow::{anyhow, Result};
use async_trait::async_trait; use async_trait::async_trait;
use base64::{ use base64::{
engine::{self}, engine::{self},
Engine as _, Engine as _,
}; };
use lapin::{message::Delivery, Channel as AMQPChannel, Connection};
use revolt_a2::{ use revolt_a2::{
request::{ request::{
notification::{DefaultAlert, NotificationOptions}, notification::{DefaultAlert, NotificationOptions},
@@ -43,7 +42,7 @@ impl<'a> PayloadLike for MessagePayload<'a> {
fn get_device_token(&self) -> &'a str { fn get_device_token(&self) -> &'a str {
self.device_token self.device_token
} }
fn get_options(&self) -> &NotificationOptions<'a> { fn get_options(&self) -> &NotificationOptions {
&self.options &self.options
} }
} }
@@ -69,20 +68,16 @@ impl<'a> PayloadLike for CallStartStopPayload<'a> {
fn get_device_token(&self) -> &'a str { fn get_device_token(&self) -> &'a str {
self.device_token self.device_token
} }
fn get_options(&self) -> &NotificationOptions<'a> { fn get_options(&self) -> &NotificationOptions {
&self.options &self.options
} }
} }
// region: consumer // region: consumer
#[derive(Clone)]
#[allow(unused)]
pub struct ApnsOutboundConsumer { pub struct ApnsOutboundConsumer {
#[allow(dead_code)]
db: Database, db: Database,
authifier_db: authifier::Database,
connection: Arc<Connection>,
channel: Arc<AMQPChannel>,
client: Client, client: Client,
} }
@@ -122,21 +117,15 @@ impl ApnsOutboundConsumer {
} }
} }
#[async_trait] impl ApnsOutboundConsumer {
impl Consumer for ApnsOutboundConsumer { pub async fn new(db: Database) -> Result<ApnsOutboundConsumer, &'static str> {
async fn create(
db: Database,
authifier_db: authifier::Database,
connection: Arc<Connection>,
channel: Arc<AMQPChannel>,
) -> Self {
let config = revolt_config::config().await; let config = revolt_config::config().await;
if config.pushd.apn.pkcs8.is_empty() if config.pushd.apn.pkcs8.is_empty()
|| config.pushd.apn.key_id.is_empty() || config.pushd.apn.key_id.is_empty()
|| config.pushd.apn.team_id.is_empty() || config.pushd.apn.team_id.is_empty()
{ {
panic!("Missing APN keys."); return Err("Missing APN keys.");
} }
let endpoint = if config.pushd.apn.sandbox { let endpoint = if config.pushd.apn.sandbox {
@@ -159,21 +148,18 @@ impl Consumer for ApnsOutboundConsumer {
) )
.expect("could not create APN client"); .expect("could not create APN client");
Self { Ok(ApnsOutboundConsumer { db, client })
db,
authifier_db,
connection,
channel,
client,
}
} }
fn channel(&self) -> &Arc<AMQPChannel> { async fn consume_event(
&self.channel &mut self,
} _channel: &AmqpChannel,
_deliver: Deliver,
async fn consume(&self, delivery: Delivery) -> Result<()> { _basic_properties: BasicProperties,
let payload: PayloadToService = serde_json::from_slice(&delivery.data)?; content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let payload: PayloadToService = serde_json::from_str(content.as_str())?;
let payload_options = NotificationOptions { let payload_options = NotificationOptions {
apns_id: None, apns_id: None,
@@ -184,15 +170,20 @@ impl Consumer for ApnsOutboundConsumer {
apns_collapse_id: None, apns_collapse_id: None,
}; };
let resp = match payload.notification { let resp: Result<Response, Error>;
match payload.notification {
PayloadKind::FRReceived(alert) => { PayloadKind::FRReceived(alert) => {
let loc_args = vec![Cow::from( let loc_args = vec![Cow::from(
alert.from_user.display_name.clone().unwrap_or_else(|| { alert
format!( .from_user
.display_name
.or(Some(format!(
"{}#{}", "{}#{}",
alert.from_user.username, alert.from_user.discriminator alert.from_user.username, alert.from_user.discriminator
) )))
}), .clone()
.ok_or_else(|| anyhow!("missing name"))?,
)]; )];
let apn_payload = Payload { let apn_payload = Payload {
@@ -225,17 +216,20 @@ impl Consumer for ApnsOutboundConsumer {
"Sending friend request received for user: {:}", "Sending friend request received for user: {:}",
&payload.user_id &payload.user_id
); );
self.client.send(apn_payload).await resp = self.client.send(apn_payload).await;
} }
PayloadKind::FRAccepted(alert) => { PayloadKind::FRAccepted(alert) => {
let loc_args = vec![Cow::from( let loc_args = vec![Cow::from(
alert.accepted_user.display_name.clone().unwrap_or_else(|| { alert
format!( .accepted_user
.display_name
.or(Some(format!(
"{}#{}", "{}#{}",
alert.accepted_user.username, alert.accepted_user.discriminator alert.accepted_user.username, alert.accepted_user.discriminator
) )))
}), .clone()
.ok_or_else(|| anyhow!("missing name"))?,
)]; )];
let apn_payload = Payload { let apn_payload = Payload {
@@ -268,7 +262,7 @@ impl Consumer for ApnsOutboundConsumer {
"Sending friend request accept for user: {:}", "Sending friend request accept for user: {:}",
&payload.user_id &payload.user_id
); );
self.client.send(apn_payload).await resp = self.client.send(apn_payload).await;
} }
PayloadKind::Generic(alert) => { PayloadKind::Generic(alert) => {
let apn_payload = Payload { let apn_payload = Payload {
@@ -301,7 +295,7 @@ impl Consumer for ApnsOutboundConsumer {
"Sending generic notification for user: {:}", "Sending generic notification for user: {:}",
&payload.user_id &payload.user_id
); );
self.client.send(apn_payload).await resp = self.client.send(apn_payload).await;
} }
PayloadKind::MessageNotification(alert) => { PayloadKind::MessageNotification(alert) => {
@@ -340,7 +334,7 @@ impl Consumer for ApnsOutboundConsumer {
"Sending message notification for user: {:}", "Sending message notification for user: {:}",
&payload.user_id &payload.user_id
); );
self.client.send(apn_payload).await resp = self.client.send(apn_payload).await;
} }
PayloadKind::BadgeUpdate(badge) => { PayloadKind::BadgeUpdate(badge) => {
@@ -355,7 +349,7 @@ impl Consumer for ApnsOutboundConsumer {
}; };
debug!("Sending badge update for user: {:}", &payload.user_id); debug!("Sending badge update for user: {:}", &payload.user_id);
self.client.send(apn_payload).await resp = self.client.send(apn_payload).await;
} }
PayloadKind::DmCallStartEnd(alert) => { PayloadKind::DmCallStartEnd(alert) => {
@@ -384,37 +378,58 @@ impl Consumer for ApnsOutboundConsumer {
"Sending call start/stop notification for user: {:}", "Sending call start/stop notification for user: {:}",
&payload.user_id &payload.user_id
); );
self.client.send(apn_payload).await resp = self.client.send(apn_payload).await;
} }
}; }
match resp { if let Err(err) = resp {
Err(Error::ResponseError(Response { match err {
error: Error::ResponseError(Response {
Some(ErrorBody { error:
reason: ErrorReason::BadDeviceToken | ErrorReason::Unregistered, Some(ErrorBody {
.. reason: ErrorReason::BadDeviceToken | ErrorReason::Unregistered,
}), ..
.. }),
})) => { ..
info!( }) => {
"Removing APNS subscription id {:} (user: {:}) due to invalid token", info!(
&payload.session_id, &payload.user_id "Removing APNS subscription id {:} (user: {:}) due to invalid token",
); &payload.session_id, &payload.user_id
);
if let Err(err) = self if let Err(err) = self
.db .db
.remove_push_subscription_by_session_id(&payload.session_id) .remove_push_subscription_by_session_id(&payload.session_id)
.await .await
{ {
revolt_config::capture_error(&err);
}
}
err => {
revolt_config::capture_error(&err); revolt_config::capture_error(&err);
} }
} }
resp => { }
resp?;
}
};
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for ApnsOutboundConsumer {
async fn consume(
&mut self,
channel: &AmqpChannel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process APN event: {err:?}");
}
}
}
+139 -184
View File
@@ -1,145 +1,51 @@
use std::{collections::HashMap, sync::Arc, time::Duration}; use std::{collections::HashMap, time::Duration};
use crate::utils::Consumer; use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProperties, Deliver};
use anyhow::{bail, Result};
use anyhow::{anyhow, bail, Result};
use async_trait::async_trait; use async_trait::async_trait;
use fcm_v1::{ use fcm_v1::{
android::{AndroidConfig, AndroidMessagePriority},
auth::{Authenticator, ServiceAccountKey}, auth::{Authenticator, ServiceAccountKey},
message::Message, message::{Message, Notification},
Client, Error as FcmError, Client, Error as FcmError,
}; };
use lapin::{message::Delivery, Channel as AMQPChannel, Connection};
use revolt_config::config; use revolt_config::config;
use revolt_database::{events::rabbit::*, Database}; use revolt_database::{events::rabbit::*, Database};
use revolt_models::v0::{Channel, PushNotification};
use serde_json::Value; use serde_json::Value;
/// Custom notification data
#[derive(Debug, Clone, PartialEq)]
pub enum NotificationData {
FRReceived {
id: String,
username: String,
},
FRAccepted {
id: String,
username: String,
},
Generic {
title: String,
body: String,
image: Option<String>,
},
Message {
message: String,
body: String,
image: String,
channel: String,
author: String,
author_name: String,
},
DmCallStartEnd {
initiator_id: String,
channel_id: String,
started_at: String,
ended: bool,
duration: usize,
},
}
impl NotificationData {
pub fn get_type(&self) -> &str {
match self {
NotificationData::FRReceived { .. } => "push.fr.receive",
NotificationData::FRAccepted { .. } => "push.fr.accept",
NotificationData::Generic { .. } => "push.generic",
NotificationData::Message { .. } => "push.message",
NotificationData::DmCallStartEnd { .. } => "push.dm.call",
}
}
pub fn into_payload(self) -> HashMap<String, Value> {
let mut data = HashMap::new();
data.insert(
"type".to_string(),
Value::String(self.get_type().to_string()),
);
match self {
NotificationData::FRReceived { id, username } => {
data.insert("id".to_string(), Value::String(id));
data.insert("username".to_string(), Value::String(username));
}
NotificationData::FRAccepted { id, username } => {
data.insert("id".to_string(), Value::String(id));
data.insert("username".to_string(), Value::String(username));
}
NotificationData::Generic { title, body, image } => {
data.insert("title".to_string(), Value::String(title));
data.insert("body".to_string(), Value::String(body));
if let Some(image) = image {
data.insert("image".to_string(), Value::String(image));
}
}
NotificationData::Message {
message,
body,
image,
channel,
author,
author_name,
} => {
data.insert("message".to_string(), Value::String(message));
data.insert("body".to_string(), Value::String(body));
data.insert("image".to_string(), Value::String(image));
data.insert("channel".to_string(), Value::String(channel));
data.insert("author".to_string(), Value::String(author));
data.insert("author_name".to_string(), Value::String(author_name));
}
NotificationData::DmCallStartEnd {
initiator_id,
channel_id,
started_at,
ended,
duration,
} => {
data.insert("initiator_id".to_string(), Value::String(initiator_id));
data.insert("channel_id".to_string(), Value::String(channel_id));
data.insert("started_at".to_string(), Value::String(started_at));
data.insert("ended".to_string(), Value::Bool(ended));
data.insert("duration".to_string(), Value::Number(duration.into()));
}
}
data
}
}
#[derive(Clone)]
#[allow(unused)]
pub struct FcmOutboundConsumer { pub struct FcmOutboundConsumer {
db: Database, db: Database,
authifier_db: authifier::Database,
connection: Arc<Connection>,
channel: Arc<AMQPChannel>,
client: Client, client: Client,
} }
#[async_trait] impl FcmOutboundConsumer {
impl Consumer for FcmOutboundConsumer { fn format_title(&self, notification: &PushNotification) -> String {
async fn create( // ideally this changes depending on context
db: Database, // in a server, it would look like "Sendername, #channelname in servername"
authifier_db: authifier::Database, // in a group, it would look like "Sendername in groupname"
connection: Arc<Connection>, // in a dm it should just be "Sendername".
channel: Arc<AMQPChannel>, // not sure how feasible all those are given the PushNotification object as it currently stands.
) -> Self {
#[allow(deprecated)]
match &notification.channel {
Channel::DirectMessage { .. } => notification.author.clone(),
Channel::Group { name, .. } => format!("{}, #{}", notification.author, name),
Channel::TextChannel { name, .. } => {
format!("{} in #{}", notification.author, name)
}
_ => "Unknown".to_string(),
}
}
}
impl FcmOutboundConsumer {
pub async fn new(db: Database) -> Result<FcmOutboundConsumer, &'static str> {
let config = revolt_config::config().await; let config = revolt_config::config().await;
Self { Ok(FcmOutboundConsumer {
db, db,
authifier_db,
connection,
channel,
client: Client::new( client: Client::new(
Authenticator::service_account::<&str>(ServiceAccountKey { Authenticator::service_account::<&str>(ServiceAccountKey {
key_type: Some(config.pushd.fcm.key_type), key_type: Some(config.pushd.fcm.key_type),
@@ -159,36 +65,45 @@ impl Consumer for FcmOutboundConsumer {
false, false,
Duration::from_secs(5), Duration::from_secs(5),
), ),
} })
} }
fn channel(&self) -> &Arc<AMQPChannel> { async fn consume_event(
&self.channel &mut self,
} _channel: &AmqpChannel,
_deliver: Deliver,
async fn consume(&self, delivery: Delivery) -> Result<()> { _basic_properties: BasicProperties,
let payload: PayloadToService = serde_json::from_slice(&delivery.data)?; content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let payload: PayloadToService = serde_json::from_str(content.as_str())?;
#[allow(clippy::needless_late_init)] #[allow(clippy::needless_late_init)]
let resp: Result<Message, FcmError>; let resp: Result<Message, FcmError>;
match payload.notification { match payload.notification {
PayloadKind::FRReceived(alert) => { PayloadKind::FRReceived(alert) => {
let name = alert.from_user.display_name.clone().unwrap_or_else(|| { let name = alert
format!( .from_user
.display_name
.or(Some(format!(
"{}#{}", "{}#{}",
alert.from_user.username, alert.from_user.discriminator alert.from_user.username, alert.from_user.discriminator
) )))
}); .clone()
.ok_or_else(|| anyhow!("missing name"))?;
let data = NotificationData::FRReceived { let mut data = HashMap::new();
id: alert.from_user.id, data.insert(
username: name, "type".to_string(),
}; Value::String("push.fr.receive".to_string()),
);
data.insert("id".to_string(), Value::String(alert.from_user.id));
data.insert("username".to_string(), Value::String(name));
let msg = Message { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
data: Some(data.into_payload()), data: Some(data),
..Default::default() ..Default::default()
}; };
@@ -196,36 +111,40 @@ impl Consumer for FcmOutboundConsumer {
} }
PayloadKind::FRAccepted(alert) => { PayloadKind::FRAccepted(alert) => {
let name = alert.accepted_user.display_name.clone().unwrap_or_else(|| { let name = alert
format!( .accepted_user
.display_name
.or(Some(format!(
"{}#{}", "{}#{}",
alert.accepted_user.username, alert.accepted_user.discriminator alert.accepted_user.username, alert.accepted_user.discriminator
) )))
}); .clone()
.ok_or_else(|| anyhow!("missing name"))?;
let data = NotificationData::FRAccepted { let mut data: HashMap<String, Value> = HashMap::new();
id: alert.accepted_user.id, data.insert(
username: name, "type".to_string(),
}; Value::String("push.fr.accept".to_string()),
);
data.insert("id".to_string(), Value::String(alert.accepted_user.id));
data.insert("username".to_string(), Value::String(name));
let msg = Message { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
data: Some(data.into_payload()), data: Some(data),
..Default::default() ..Default::default()
}; };
resp = self.client.send(&msg).await; resp = self.client.send(&msg).await;
} }
PayloadKind::Generic(alert) => { PayloadKind::Generic(alert) => {
let data = NotificationData::Generic {
title: alert.title,
body: alert.body,
image: alert.icon,
};
let msg = Message { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
data: Some(data.into_payload()), notification: Some(Notification {
title: Some(alert.title),
body: Some(alert.body),
image: alert.icon,
}),
..Default::default() ..Default::default()
}; };
@@ -233,18 +152,19 @@ impl Consumer for FcmOutboundConsumer {
} }
PayloadKind::MessageNotification(alert) => { PayloadKind::MessageNotification(alert) => {
let data = NotificationData::Message { let title = self.format_title(&alert);
message: alert.message.id,
body: alert.body,
image: alert.icon,
channel: alert.message.channel,
author: alert.message.author,
author_name: alert.author,
};
let msg = Message { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
data: Some(data.into_payload()), notification: Some(Notification {
title: Some(title),
body: Some(alert.body),
image: Some(alert.icon),
}),
android: Some(AndroidConfig {
collapse_key: Some(alert.tag),
..Default::default()
}),
..Default::default() ..Default::default()
}; };
@@ -252,17 +172,30 @@ impl Consumer for FcmOutboundConsumer {
} }
PayloadKind::DmCallStartEnd(alert) => { PayloadKind::DmCallStartEnd(alert) => {
let data = NotificationData::DmCallStartEnd { let mut data: HashMap<String, Value> = HashMap::new();
initiator_id: alert.initiator_id, data.insert(
channel_id: alert.channel_id, "initiator_id".to_string(),
started_at: alert.started_at.unwrap_or_else(|| "".to_string()), Value::String(alert.initiator_id),
ended: alert.ended, );
duration: config().await.api.livekit.call_ring_duration, data.insert("channel_id".to_string(), Value::String(alert.channel_id));
}; data.insert(
"started_at".to_string(),
Value::String(alert.started_at.unwrap_or_else(|| "".to_string())),
);
data.insert("ended".to_string(), Value::Bool(alert.ended));
let msg = Message { let msg = Message {
token: Some(payload.token), token: Some(payload.token),
data: Some(data.into_payload()), notification: None,
data: Some(data),
android: Some(AndroidConfig {
priority: Some(AndroidMessagePriority::High),
ttl: Some(format!(
"{}s",
config().await.api.livekit.call_ring_duration
)),
..Default::default()
}),
..Default::default() ..Default::default()
}; };
@@ -274,21 +207,43 @@ impl Consumer for FcmOutboundConsumer {
} }
} }
match resp { if let Err(err) = resp {
Err(FcmError::Auth) => { match err {
if let Err(err) = self FcmError::Auth => {
.db if let Err(err) = self
.remove_push_subscription_by_session_id(&payload.session_id) .db
.await .remove_push_subscription_by_session_id(&payload.session_id)
{ .await
{
revolt_config::capture_error(&err);
}
}
err => {
revolt_config::capture_error(&err); revolt_config::capture_error(&err);
} }
} }
res => { }
res?;
}
};
Ok(()) Ok(())
} }
} }
#[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for FcmOutboundConsumer {
async fn consume(
&mut self,
channel: &AmqpChannel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process FCM event: {err:?}");
}
}
}
@@ -1,6 +1,6 @@
use std::{collections::HashMap, sync::Arc}; use std::collections::HashMap;
use crate::utils::Consumer; use amqprs::{channel::Channel as AmqpChannel, consumer::AsyncConsumer, BasicProperties, Deliver};
use anyhow::{anyhow, bail, Result}; use anyhow::{anyhow, bail, Result};
use async_trait::async_trait; use async_trait::async_trait;
@@ -8,60 +8,46 @@ use base64::{
engine::{self}, engine::{self},
Engine as _, Engine as _,
}; };
use lapin::{message::Delivery, Channel as AMQPChannel, Connection};
use revolt_database::{events::rabbit::*, util::format_display_name, Database}; use revolt_database::{events::rabbit::*, util::format_display_name, Database};
use web_push::{ use web_push::{
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys, VapidSignatureBuilder, ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys, VapidSignatureBuilder,
WebPushClient, WebPushError, WebPushMessageBuilder, WebPushClient, WebPushError, WebPushMessageBuilder,
}; };
#[derive(Clone)]
#[allow(unused)]
pub struct VapidOutboundConsumer { pub struct VapidOutboundConsumer {
db: Database, db: Database,
authifier_db: authifier::Database,
connection: Arc<Connection>,
channel: Arc<AMQPChannel>,
client: IsahcWebPushClient, client: IsahcWebPushClient,
pkey: Arc<Vec<u8>>, pkey: Vec<u8>,
} }
#[async_trait] impl VapidOutboundConsumer {
impl Consumer for VapidOutboundConsumer { pub async fn new(db: Database) -> Result<VapidOutboundConsumer> {
async fn create(
db: Database,
authifier_db: authifier::Database,
connection: Arc<Connection>,
channel: Arc<AMQPChannel>,
) -> Self {
let config = revolt_config::config().await; let config = revolt_config::config().await;
if config.pushd.vapid.private_key.is_empty() || config.pushd.vapid.public_key.is_empty() { if config.pushd.vapid.private_key.is_empty() | config.pushd.vapid.public_key.is_empty() {
panic!("no Vapid keys present"); bail!("no Vapid keys present");
} }
let web_push_private_key = Arc::new( let web_push_private_key = engine::general_purpose::URL_SAFE_NO_PAD
engine::general_purpose::URL_SAFE_NO_PAD .decode(config.pushd.vapid.private_key)
.decode(config.pushd.vapid.private_key) .expect("valid `VAPID_PRIVATE_KEY`");
.expect("valid `VAPID_PRIVATE_KEY`"),
);
Self { Ok(VapidOutboundConsumer {
db, db,
authifier_db,
connection,
channel,
client: IsahcWebPushClient::new().unwrap(), client: IsahcWebPushClient::new().unwrap(),
pkey: web_push_private_key, pkey: web_push_private_key,
} })
} }
fn channel(&self) -> &Arc<AMQPChannel> { async fn consume_event(
&self.channel &mut self,
} _channel: &AmqpChannel,
_deliver: Deliver,
async fn consume(&self, delivery: Delivery) -> Result<()> { _basic_properties: BasicProperties,
let payload: PayloadToService = serde_json::from_slice(&delivery.data)?; content: Vec<u8>,
) -> Result<()> {
let content = String::from_utf8(content)?;
let payload: PayloadToService = serde_json::from_str(content.as_str())?;
let subscription = SubscriptionInfo { let subscription = SubscriptionInfo {
endpoint: payload endpoint: payload
@@ -79,7 +65,10 @@ impl Consumer for VapidOutboundConsumer {
}, },
}; };
let payload_body = match payload.notification { #[allow(clippy::needless_late_init)]
let payload_body: String;
match payload.notification {
PayloadKind::FRReceived(alert) => { PayloadKind::FRReceived(alert) => {
let name = alert let name = alert
.from_user .from_user
@@ -94,7 +83,7 @@ impl Consumer for VapidOutboundConsumer {
let mut body = HashMap::new(); let mut body = HashMap::new();
body.insert("body", format!("{} sent you a friend request", name)); body.insert("body", format!("{} sent you a friend request", name));
serde_json::to_string(&body)? payload_body = serde_json::to_string(&body)?;
} }
PayloadKind::FRAccepted(alert) => { PayloadKind::FRAccepted(alert) => {
let name = alert let name = alert
@@ -110,10 +99,14 @@ impl Consumer for VapidOutboundConsumer {
let mut body = HashMap::new(); let mut body = HashMap::new();
body.insert("body", format!("{} accepted your friend request", name)); body.insert("body", format!("{} accepted your friend request", name));
serde_json::to_string(&body)? payload_body = serde_json::to_string(&body)?;
}
PayloadKind::Generic(alert) => {
payload_body = serde_json::to_string(&alert)?;
}
PayloadKind::MessageNotification(alert) => {
payload_body = serde_json::to_string(&alert)?;
} }
PayloadKind::Generic(alert) => serde_json::to_string(&alert)?,
PayloadKind::MessageNotification(alert) => serde_json::to_string(&alert)?,
PayloadKind::DmCallStartEnd(alert) => { PayloadKind::DmCallStartEnd(alert) => {
let initiator_name = if let Some(server_id) = let initiator_name = if let Some(server_id) =
self.db.fetch_channel(&alert.channel_id).await?.server() self.db.fetch_channel(&alert.channel_id).await?.server()
@@ -139,41 +132,59 @@ impl Consumer for VapidOutboundConsumer {
_ => bail!("Invalid DmCallStart/End channel type"), _ => bail!("Invalid DmCallStart/End channel type"),
} }
serde_json::to_string(&body)? payload_body = serde_json::to_string(&body)?;
} }
PayloadKind::BadgeUpdate(_) => { PayloadKind::BadgeUpdate(_) => {
bail!("Vapid cannot handle badge updates and they should not be sent here."); bail!("Vapid cannot handle badge updates and they should not be sent here.");
} }
}; }
let signature = VapidSignatureBuilder::from_pem( match VapidSignatureBuilder::from_pem(std::io::Cursor::new(&self.pkey), &subscription) {
std::io::Cursor::new(self.pkey.as_ref()), Ok(sig_builder) => match sig_builder.build() {
&subscription, Ok(signature) => {
)? let mut builder = WebPushMessageBuilder::new(&subscription);
.build()?; builder.set_vapid_signature(signature);
let mut builder = WebPushMessageBuilder::new(&subscription); builder.set_payload(ContentEncoding::AesGcm, payload_body.as_bytes());
builder.set_vapid_signature(signature);
builder.set_payload(ContentEncoding::AesGcm, payload_body.as_bytes()); match builder.build() {
Ok(msg) => {
if let Err(err) = self.client.send(msg).await {
if err == WebPushError::Unauthorized {
self.db
.remove_push_subscription_by_session_id(&payload.session_id)
.await?;
}
}
let msg = builder.build()?; Ok(())
}
match self.client.send(msg).await { Err(err) => Err(err.into()),
Err(WebPushError::Unauthorized) => { }
if let Err(err) = self
.db
.remove_push_subscription_by_session_id(&payload.session_id)
.await
{
revolt_config::capture_error(&err);
} }
} Err(err) => Err(err.into()),
res => { },
res?; Err(err) => Err(err.into()),
} }
}; }
}
Ok(()) #[allow(unused_variables)]
#[async_trait]
impl AsyncConsumer for VapidOutboundConsumer {
async fn consume(
&mut self,
channel: &AmqpChannel,
deliver: Deliver,
basic_properties: BasicProperties,
content: Vec<u8>,
) {
if let Err(err) = self
.consume_event(channel, deliver, basic_properties, content)
.await
{
revolt_config::capture_anyhow(&err);
eprintln!("Failed to process Vapid event: {err:?}");
}
} }
} }
+101 -150
View File
@@ -1,16 +1,17 @@
#[macro_use] #[macro_use]
extern crate log; extern crate log;
use std::sync::Arc; use amqprs::{
channel::{
use lapin::{ BasicConsumeArguments, Channel, ExchangeDeclareArguments, QueueBindArguments,
options::{BasicConsumeOptions, ExchangeDeclareOptions, QueueBindOptions, QueueDeclareOptions}, QueueDeclareArguments,
types::{AMQPValue, FieldTable}, },
Channel, Connection, ConnectionProperties, connection::{Connection, OpenConnectionArguments},
consumer::AsyncConsumer,
FieldTable,
}; };
use revolt_config::{config, Settings}; use revolt_config::{config, Settings};
use revolt_database::Database; use tokio::sync::Notify;
use tokio::signal::ctrl_c;
mod consumers; mod consumers;
mod utils; mod utils;
@@ -23,8 +24,6 @@ use consumers::{
outbound::{apn::ApnsOutboundConsumer, fcm::FcmOutboundConsumer, vapid::VapidOutboundConsumer}, outbound::{apn::ApnsOutboundConsumer, fcm::FcmOutboundConsumer, vapid::VapidOutboundConsumer},
}; };
use crate::utils::{Consumer, Delegate};
#[tokio::main(flavor = "multi_thread", worker_threads = 2)] #[tokio::main(flavor = "multi_thread", worker_threads = 2)]
async fn main() { async fn main() {
// Configure logging and environment // Configure logging and environment
@@ -44,24 +43,7 @@ async fn main() {
panic!("Mongo is not in use, can't connect via authifier!") panic!("Mongo is not in use, can't connect via authifier!")
} }
let config = config().await; let mut connections: Vec<(Channel, Connection)> = Vec::new();
let connection = Arc::new(
Connection::connect(
&format!(
"amqp://{}:{}@{}:{}",
&config.rabbit.username,
&config.rabbit.password,
&config.rabbit.host,
&config.rabbit.port,
),
ConnectionProperties::default(),
)
.await
.expect("Failed to connect to RabbitMQ"),
);
let mut channels = Vec::new();
// An explainer of how this works: // An explainer of how this works:
// The inbound connections are on separate routing keys, such that they only receive the proper payload // The inbound connections are on separate routing keys, such that they only receive the proper payload
@@ -72,178 +54,171 @@ async fn main() {
// This'll require some interesting shimming if we need to add more events once this is in prod (different payloads between prod and test), // This'll require some interesting shimming if we need to add more events once this is in prod (different payloads between prod and test),
// but that sounds like a problem for future us. // but that sounds like a problem for future us.
channels.push( let config = config().await;
make_queue_and_consume::<GenericConsumer>(
&db, // inbound: generic
&authifier, connections.push(
&connection, make_queue_and_consume(
&config, &config,
&config.pushd.generic_queue, &config.pushd.generic_queue,
&config.pushd.get_generic_routing_key(), config.pushd.get_generic_routing_key().as_str(),
None, None,
GenericConsumer::new(db.clone(), authifier.clone()),
) )
.await, .await,
); );
channels.push( // inbound: messages
make_queue_and_consume::<MessageConsumer>( connections.push(
&db, make_queue_and_consume(
&authifier,
&connection,
&config, &config,
&config.pushd.message_queue, &config.pushd.message_queue,
&config.pushd.get_message_routing_key(), config.pushd.get_message_routing_key().as_str(),
None, None,
MessageConsumer::new(db.clone(), authifier.clone()),
) )
.await, .await,
); );
channels.push( // inbound: FR received
make_queue_and_consume::<FRReceivedConsumer>( connections.push(
&db, make_queue_and_consume(
&authifier,
&connection,
&config, &config,
&config.pushd.fr_received_queue, &config.pushd.fr_received_queue,
&config.pushd.get_fr_received_routing_key(), config.pushd.get_fr_received_routing_key().as_str(),
None, None,
FRReceivedConsumer::new(db.clone(), authifier.clone()),
) )
.await, .await,
); );
channels.push( // inbound: FR accepted
make_queue_and_consume::<FRAcceptedConsumer>( connections.push(
&db, make_queue_and_consume(
&authifier,
&connection,
&config, &config,
&config.pushd.fr_accepted_queue, &config.pushd.fr_accepted_queue,
&config.pushd.get_fr_accepted_routing_key(), config.pushd.get_fr_accepted_routing_key().as_str(),
None, None,
FRAcceptedConsumer::new(db.clone(), authifier.clone()),
) )
.await, .await,
); );
channels.push( // inbound: Mass Mentions
make_queue_and_consume::<MassMessageConsumer>( connections.push(
&db, make_queue_and_consume(
&authifier,
&connection,
&config, &config,
&config.pushd.mass_mention_queue, &config.pushd.mass_mention_queue,
&config.pushd.get_mass_mention_routing_key(), config.pushd.get_mass_mention_routing_key().as_str(),
None, None,
MassMessageConsumer::new(db.clone(), authifier.clone()),
) )
.await, .await,
); );
channels.push( // inbound: Dm Calls
make_queue_and_consume::<DmCallConsumer>( connections.push(
&db, make_queue_and_consume(
&authifier,
&connection,
&config, &config,
&config.pushd.dm_call_queue, &config.pushd.dm_call_queue,
&config.pushd.get_dm_call_routing_key(), config.pushd.get_dm_call_routing_key().as_str(),
None, None,
DmCallConsumer::new(db.clone(), authifier.clone()),
) )
.await, .await,
); );
if !config.pushd.apn.pkcs8.is_empty() { if !config.pushd.apn.pkcs8.is_empty() {
channels.push( connections.push(
make_queue_and_consume::<ApnsOutboundConsumer>( make_queue_and_consume(
&db,
&authifier,
&connection,
&config, &config,
&config.pushd.apn.queue, &config.pushd.apn.queue,
&config.pushd.apn.queue, &config.pushd.apn.queue,
None, None,
ApnsOutboundConsumer::new(db.clone()).await.unwrap(),
) )
.await, .await,
); );
let mut table = FieldTable::default(); let mut table = FieldTable::new();
table.insert("x-message-deduplication".into(), AMQPValue::Boolean(true)); table.insert("x-message-deduplication".try_into().unwrap(), "true".into());
channels.push( connections.push(
make_queue_and_consume::<AckConsumer>( make_queue_and_consume(
&db,
&authifier,
&connection,
&config, &config,
&config.pushd.ack_queue, &config.pushd.ack_queue,
&config.pushd.ack_queue, &config.pushd.ack_queue,
Some(table), Some(table),
AckConsumer::new(db.clone(), authifier.clone()),
) )
.await, .await,
); );
} }
if !config.pushd.fcm.auth_uri.is_empty() { if !config.pushd.fcm.auth_uri.is_empty() {
channels.push( connections.push(
make_queue_and_consume::<FcmOutboundConsumer>( make_queue_and_consume(
&db,
&authifier,
&connection,
&config, &config,
&config.pushd.fcm.queue, &config.pushd.fcm.queue,
&config.pushd.fcm.queue, &config.pushd.fcm.queue,
None, None,
FcmOutboundConsumer::new(db.clone()).await.unwrap(),
) )
.await, .await,
); )
} }
if !config.pushd.vapid.public_key.is_empty() { if !config.pushd.vapid.public_key.is_empty() {
channels.push( connections.push(
make_queue_and_consume::<VapidOutboundConsumer>( make_queue_and_consume(
&db,
&authifier,
&connection,
&config, &config,
&config.pushd.vapid.queue, &config.pushd.vapid.queue,
&config.pushd.vapid.queue, &config.pushd.vapid.queue,
None, None,
VapidOutboundConsumer::new(db.clone()).await.unwrap(),
) )
.await, .await,
); )
} }
ctrl_c().await.unwrap(); let guard = Notify::new();
guard.notified().await;
for channel in channels { for (channel, conn) in connections {
let _ = channel.close(0, "close".into()).await; channel.close().await.expect("Unable to close channel");
conn.close().await.expect("Unable to close connection");
} }
} }
async fn make_queue_and_consume<F>( async fn make_queue_and_consume<F>(
db: &Database,
authifier_db: &authifier::Database,
connection: &Arc<Connection>,
config: &Settings, config: &Settings,
queue_name: &str, queue_name: &str,
routing_key: &str, routing_key: &str,
queue_args: Option<FieldTable>, queue_args: Option<FieldTable>,
) -> Arc<Channel> consumer: F,
) -> (Channel, Connection)
where where
F: Consumer, F: AsyncConsumer + Send + 'static,
{ {
let channel = Arc::new(connection.create_channel().await.unwrap()); let connection = Connection::open(&OpenConnectionArguments::new(
&config.rabbit.host,
config.rabbit.port,
&config.rabbit.username,
&config.rabbit.password,
))
.await
.unwrap();
let channel = connection.open_channel(None).await.unwrap();
channel channel
.exchange_declare( .exchange_declare(
config.pushd.exchange.clone().into(), ExchangeDeclareArguments::new(&config.pushd.exchange, "direct")
lapin::ExchangeKind::Direct, .durable(true)
ExchangeDeclareOptions { .finish(),
durable: true,
..Default::default()
},
FieldTable::default(),
) )
.await .await
.expect("Failed to declare exchange"); .expect("Failed to declare pushd exchange");
let mut queue_name = queue_name.to_string(); let mut queue_name = queue_name.to_string();
@@ -255,59 +230,35 @@ where
let queue_name = queue_name.as_str(); let queue_name = queue_name.as_str();
let args = QueueDeclareOptions { let mut args = QueueDeclareArguments::new(queue_name);
durable: true, args.durable(true);
..Default::default()
}; if let Some(arg) = queue_args {
args.arguments(arg);
}
let args = args.finish();
_ = channel.queue_declare(args).await.unwrap().unwrap();
channel channel
.queue_declare(queue_name.into(), args, queue_args.unwrap_or_default()) .queue_bind(QueueBindArguments::new(
.await queue_name,
.unwrap(); &config.pushd.exchange,
routing_key,
channel ))
.queue_bind(
queue_name.into(),
config.pushd.exchange.clone().into(),
routing_key.into(),
QueueBindOptions::default(),
FieldTable::default(),
)
.await .await
.expect( .expect(
"This probably means the revolt.notifications exchange does not exist in rabbitmq!", "This probably means the revolt.notifications exchange does not exist in rabbitmq!",
); );
let consumer = channel let args = BasicConsumeArguments::new(queue_name, "")
.basic_consume( .manual_ack(false)
queue_name.into(), .finish();
"".into(),
BasicConsumeOptions { let routing_key = channel.basic_consume(consumer, args).await.unwrap();
no_ack: true,
..Default::default()
},
FieldTable::default(),
)
.await
.unwrap();
info!( info!(
"Consuming routing key {} as queue {}, tag {}", "Consuming routing key {} as queue {}, tag {}",
routing_key, routing_key, queue_name, routing_key
queue_name,
consumer.tag()
); );
(channel, connection)
let delegate = Delegate(
F::create(
db.clone(),
authifier_db.clone(),
connection.clone(),
channel.clone(),
)
.await,
);
consumer.set_delegate(delegate);
channel
} }
@@ -1,91 +0,0 @@
use std::{
future::{ready, Future},
pin::Pin,
sync::Arc,
};
use anyhow::Result;
use async_trait::async_trait;
use lapin::{
message::{Delivery, DeliveryResult},
options::BasicPublishOptions,
BasicProperties, Channel, Connection, ConsumerDelegate, Error as AMQPError,
};
use log::debug;
use revolt_database::Database;
#[async_trait]
pub trait Consumer: Clone + Send + Sync + 'static {
async fn create(
db: Database,
authifier_db: authifier::Database,
connection: Arc<Connection>,
channel: Arc<Channel>,
) -> Self;
fn channel(&self) -> &Arc<Channel>;
async fn consume(&self, delivery: Delivery) -> Result<()>;
async fn publish_message_with_options(
&self,
payload: &[u8],
exchange: &str,
routing_key: &str,
options: BasicPublishOptions,
properties: BasicProperties,
) -> Result<(), AMQPError> {
let channel = self.channel();
channel
.basic_publish(
exchange.into(),
routing_key.into(),
options,
payload,
properties,
)
.await?;
debug!("Sent message to queue for target {}", routing_key);
Ok(())
}
async fn publish_message(
&self,
payload: &[u8],
exchange: &str,
routing_key: &str,
) -> Result<(), AMQPError> {
self.publish_message_with_options(
payload,
exchange,
routing_key,
BasicPublishOptions::default(),
BasicProperties::default(),
)
.await
}
}
pub struct Delegate<C: Consumer>(pub C);
impl<C: Consumer> ConsumerDelegate for Delegate<C> {
fn on_new_delivery(
&self,
delivery: DeliveryResult,
) -> Pin<Box<dyn Future<Output = ()> + Send>> {
match delivery {
Ok(Some(delivery)) => {
let consumer = self.0.clone();
Box::pin(async move {
if let Err(e) = consumer.consume(delivery).await {
revolt_config::capture_anyhow(&e);
log::error!("{e:?}");
};
})
}
Ok(None) => Box::pin(ready(())),
Err(e) => Box::pin(async move { log::error!("Received bad delivery: {e:?}") }),
}
}
}
-3
View File
@@ -1,5 +1,2 @@
mod renderer; mod renderer;
mod consumer;
pub use renderer::render_notification_content; pub use renderer::render_notification_content;
pub use consumer::{Consumer, Delegate};
+25 -22
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-voice-ingress" name = "revolt-voice-ingress"
version = "0.13.7" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
publish = false publish = false
@@ -9,38 +9,41 @@ publish = false
[dependencies] [dependencies]
# util # util
log = { workspace = true } log = "*"
sentry = { workspace = true } sentry = "0.31.5"
lru = { workspace = true } lru = "0.7.6"
ulid = { workspace = true } ulid = "0.5.0"
redis-kiss = { workspace = true } redis-kiss = "0.1.4"
chrono = { workspace = true } chrono = "0.4.15"
# Serde # Serde
serde_json = { workspace = true } serde_json = "1.0.79"
rmp-serde = { workspace = true } rmp-serde = "1.0.0"
serde = { workspace = true } serde = "1.0.136"
# Http # Http
rocket = { workspace = true, features = ["json"] } rocket = { version = "0.5.0-rc.2", features = ["json"] }
rocket_empty = { workspace = true } rocket_empty = "0.1.1"
# Async # Async
futures = { workspace = true } futures = "0.3.21"
async-std = { workspace = true, features = [ async-std = { version = "1.8.0", features = [
"tokio1", "tokio1",
"tokio02", "tokio02",
"attributes", "attributes",
] } ] }
# Core # Core
revolt-result = { workspace = true, features = ["rocket"] } revolt-result = { path = "../../core/result" }
revolt-models = { workspace = true } revolt-models = { path = "../../core/models" }
revolt-config = { workspace = true } revolt-config = { path = "../../core/config" }
revolt-database = { workspace = true, features = ["voice"] } revolt-database = { path = "../../core/database", features = ["voice"] }
revolt-permissions = { workspace = true } revolt-permissions = { path = "../../core/permissions" }
# Voice # Voice
livekit-api = { workspace = true } livekit-api = "0.4.4"
livekit-protocol = { workspace = true } livekit-protocol = "0.4.0"
livekit-runtime = { workspace = true, features = ["tokio"] } livekit-runtime = { version = "0.3.1", features = ["tokio"] }
# RabbitMQ
amqprs = { version = "1.7.0" }
+45 -44
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-delta" name = "revolt-delta"
version = "0.13.7" version = "0.12.0"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <paulmakles@gmail.com>"] authors = ["Paul Makles <paulmakles@gmail.com>"]
edition = "2018" edition = "2018"
@@ -10,83 +10,84 @@ publish = false
[dependencies] [dependencies]
# Test # Test
rand = { workspace = true } rand = "0.8.5"
redis-kiss = { workspace = true } redis-kiss = "0.1.4"
# Utility # Utility
lru = { workspace = true } lru = "0.7.0"
url = { workspace = true } url = "2.2.2"
log = { workspace = true } log = "0.4.11"
dashmap = { workspace = true } dashmap = "5.2.0"
linkify = { workspace = true } linkify = "0.6.0"
once_cell = { workspace = true } once_cell = "1.17.1"
env_logger = "0.7.1"
# Lang. Utilities # Lang. Utilities
regex = { workspace = true } regex = "1"
num_enum = { workspace = true } num_enum = "0.5.1"
impl_ops = { workspace = true } impl_ops = "0.1.1"
bitfield = { workspace = true } bitfield = "0.13.2"
# ID / key generation # ID / key generation
ulid = { workspace = true } ulid = "0.4.1"
nanoid = { workspace = true } nanoid = "0.4.0"
# serde # serde
serde_json = { workspace = true } serde_json = "1.0.57"
serde = { workspace = true } serde = { version = "1.0.115", features = ["derive"] }
validator = { workspace = true, features = ["derive"] } validator = { version = "0.16", features = ["derive"] }
iso8601-timestamp = { workspace = true } iso8601-timestamp = { version = "0.2.11", features = [] }
# async # async
futures = { workspace = true } futures = "0.3.8"
chrono = { workspace = true } chrono = "0.4.15"
async-channel = { workspace = true } async-channel = "1.6.1"
reqwest = { workspace = true, features = ["json"] } reqwest = { version = "0.11.4", features = ["json"] }
async-std = { workspace = true, features = [ async-std = { version = "1.8.0", features = [
"tokio1", "tokio1",
"tokio02", "tokio02",
"attributes", "attributes",
] } ] }
# internal util # internal util
lettre = { workspace = true } lettre = "0.10.0-alpha.4"
# web # web
rocket = { workspace = true, features = ["json"] } rocket = { version = "0.5.1", default-features = false, features = ["json"] }
rocket_cors = { workspace = true } rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_empty = { workspace = true, features = ["schema"] } rocket_empty = { version = "0.1.1", features = ["schema"] }
rocket_authifier = { workspace = true } rocket_authifier = { version = "1.0.16" }
rocket_prometheus = { workspace = true } rocket_prometheus = "0.10.0-rc.3"
# spec generation # spec generation
schemars = { workspace = true } schemars = "0.8.8"
revolt_rocket_okapi = { workspace = true, features = ["swagger"] } revolt_rocket_okapi = { version = "0.10.0", features = ["swagger"] }
# rabbit # rabbit
lapin = { workspace = true, features = ["tokio"] } amqprs = { version = "1.7.0" }
# core # core
authifier = { workspace = true } authifier = "1.0.16"
revolt-config = { workspace = true } revolt-config = { path = "../core/config" }
revolt-database = { workspace = true, features = [ revolt-database = { path = "../core/database", features = [
"rocket-impl", "rocket-impl",
"redis-is-patched", "redis-is-patched",
"voice", "voice",
] } ] }
revolt-models = { workspace = true, features = [ revolt-models = { path = "../core/models", features = [
"schemas", "schemas",
"validator", "validator",
"rocket", "rocket",
] } ] }
revolt-presence = { workspace = true } revolt-presence = { path = "../core/presence" }
revolt-result = { workspace = true, features = ["rocket", "okapi"] } revolt-result = { path = "../core/result", features = ["rocket", "okapi"] }
revolt-permissions = { workspace = true, features = ["schemas"] } revolt-permissions = { path = "../core/permissions", features = ["schemas"] }
revolt-ratelimits = { workspace = true, features = ["rocket"] } revolt-ratelimits = { path = "../core/ratelimits", features = ["rocket"] }
# voice # voice
livekit-api = { workspace = true } livekit-api = "0.4.4"
livekit-protocol = { workspace = true } livekit-protocol = "0.4.0"
[build-dependencies] [build-dependencies]
vergen = { workspace = true } vergen = "7.5.0"
+48 -4
View File
@@ -9,7 +9,8 @@ pub mod routes;
pub mod util; pub mod util;
use revolt_config::config; use revolt_config::config;
use revolt_database::{AMQP, events::client::EventV1}; use revolt_database::events::client::EventV1;
use revolt_database::AMQP;
use revolt_ratelimits::rocket as ratelimiter; use revolt_ratelimits::rocket as ratelimiter;
use rocket::{Build, Rocket}; use rocket::{Build, Rocket};
use rocket_cors::{AllowedOrigins, CorsOptions}; use rocket_cors::{AllowedOrigins, CorsOptions};
@@ -17,10 +18,14 @@ use rocket_prometheus::PrometheusMetrics;
use std::net::Ipv4Addr; use std::net::Ipv4Addr;
use std::str::FromStr; use std::str::FromStr;
use amqprs::{
channel::ExchangeDeclareArguments,
connection::{Connection, OpenConnectionArguments},
};
use async_std::channel::unbounded; use async_std::channel::unbounded;
use authifier::AuthifierEvent; use authifier::AuthifierEvent;
use revolt_database::voice::VoiceClient;
use rocket::data::ToByteUnit; use rocket::data::ToByteUnit;
use revolt_database::voice::VoiceClient;
pub async fn web() -> Rocket<Build> { pub async fn web() -> Rocket<Build> {
// Get settings // Get settings
@@ -31,6 +36,7 @@ pub async fn web() -> Rocket<Build> {
// Setup database // Setup database
let db = revolt_database::DatabaseInfo::Auto.connect().await.unwrap(); let db = revolt_database::DatabaseInfo::Auto.connect().await.unwrap();
log::info!("database_here {db:?}");
db.migrate_database().await.unwrap(); db.migrate_database().await.unwrap();
// Setup Authifier event channel // Setup Authifier event channel
@@ -87,11 +93,49 @@ pub async fn web() -> Rocket<Build> {
) )
.into(); .into();
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "/0.8/openapi.json".to_owned(),
..Default::default()
},
)
.into();
let swagger_0_8 = revolt_rocket_okapi::swagger_ui::make_swagger_ui(
&revolt_rocket_okapi::swagger_ui::SwaggerUIConfig {
url: "/0.8/openapi.json".to_owned(),
..Default::default()
},
)
.into();
// Voice handler // Voice handler
let voice_client = VoiceClient::new(config.api.livekit.nodes.clone()); let voice_client = VoiceClient::new(config.api.livekit.nodes.clone());
// Configure Rabbit // Configure Rabbit
let connection = Connection::open(&OpenConnectionArguments::new(
&config.rabbit.host,
config.rabbit.port,
&config.rabbit.username,
&config.rabbit.password,
))
.await
.expect("Failed to connect to RabbitMQ");
let amqp = AMQP::new_auto().await; let channel = connection
.open_channel(None)
.await
.expect("Failed to open RabbitMQ channel");
channel
.exchange_declare(
ExchangeDeclareArguments::new(&config.pushd.exchange, "direct")
.durable(true)
.finish(),
)
.await
.expect("Failed to declare exchange");
let amqp = AMQP::new(connection, channel);
// Launch background task workers // Launch background task workers
revolt_database::tasks::start_workers(db.clone(), amqp.clone()); revolt_database::tasks::start_workers(db.clone(), amqp.clone());
@@ -109,6 +153,7 @@ pub async fn web() -> Rocket<Build> {
.mount("/", rocket_cors::catch_all_options_routes()) .mount("/", rocket_cors::catch_all_options_routes())
.mount("/", ratelimiter::routes()) .mount("/", ratelimiter::routes())
.mount("/swagger/", swagger) .mount("/swagger/", swagger)
.mount("/0.8/swagger/", swagger_0_8)
.manage(authifier) .manage(authifier)
.manage(db) .manage(db)
.manage(amqp) .manage(amqp)
@@ -121,7 +166,6 @@ pub async fn web() -> Rocket<Build> {
limits: rocket::data::Limits::default().limit("string", 5.megabytes()), limits: rocket::data::Limits::default().limit("string", 5.megabytes()),
address: Ipv4Addr::new(0, 0, 0, 0).into(), address: Ipv4Addr::new(0, 0, 0, 0).into(),
port: 14702, port: 14702,
ip_header: Some("X-Forwarded-For".into()),
..Default::default() ..Default::default()
}) })
} }
@@ -1,6 +1,6 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, User, AMQP, Database, User,
}; };
use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
@@ -14,7 +14,6 @@ use rocket_empty::EmptyResponse;
#[put("/<target>/ack/<message>")] #[put("/<target>/ack/<message>")]
pub async fn ack( pub async fn ack(
db: &State<Database>, db: &State<Database>,
amqp: &State<AMQP>,
user: User, user: User,
target: Reference<'_>, target: Reference<'_>,
message: Reference<'_>, message: Reference<'_>,
@@ -30,7 +29,7 @@ pub async fn ack(
.throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?; .throw_if_lacking_channel_permission(ChannelPermission::ViewChannel)?;
channel channel
.ack(&user.id, message.id, amqp) .ack(&user.id, message.id)
.await .await
.map(|_| EmptyResponse) .map(|_| EmptyResponse)
} }
@@ -1,5 +1,4 @@
use std::time::Duration; use chrono::Utc;
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, Message, User, Database, Message, User,
@@ -37,9 +36,10 @@ pub async fn bulk_delete_messages(
if ulid::Ulid::from_string(id) if ulid::Ulid::from_string(id)
.map_err(|_| create_error!(InvalidOperation))? .map_err(|_| create_error!(InvalidOperation))?
.datetime() .datetime()
.elapsed() .signed_duration_since(Utc::now())
.expect("Time went backwards") .num_days()
> Duration::from_hours(7 * 24) // 7 days .abs()
> 7
{ {
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation));
} }
@@ -1,14 +1,11 @@
use std::time::Duration; use chrono::{Duration, Utc};
use redis_kiss::{get_connection, redis, AsyncCommands}; use redis_kiss::{get_connection, redis, AsyncCommands};
use revolt_database::events::client::EventV1;
use revolt_database::util::permissions::DatabasePermissionQuery; use revolt_database::util::permissions::DatabasePermissionQuery;
use revolt_database::{ use revolt_database::{
util::idempotency::IdempotencyKey, util::reference::Reference, Database, User, util::idempotency::IdempotencyKey, util::reference::Reference, Database, User,
}; };
use revolt_database::{Channel, Interactions, Message, AMQP}; use revolt_database::{Channel, Interactions, Message, AMQP};
use revolt_models::v0; use revolt_models::v0;
use revolt_models::v0::ChannelSlowmode;
use revolt_permissions::PermissionQuery; use revolt_permissions::PermissionQuery;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
@@ -86,16 +83,6 @@ pub async fn message_send(
.await .await
.unwrap_or(None); .unwrap_or(None);
if set_result.is_some() {
let idx_key = format!("slowmode_idx:{}", user.id);
conn.sadd::<_, _, ()>(&idx_key, channel_id.as_str())
.await
.ok();
conn.expire::<_, ()>(&idx_key, *channel_slowmode as usize)
.await
.ok();
}
// If `set_result` is None, the `NX` condition failed because the key already exists. // If `set_result` is None, the `NX` condition failed because the key already exists.
// This means the user is currently in slowmode. // This means the user is currently in slowmode.
if set_result.is_none() { if set_result.is_none() {
@@ -104,29 +91,10 @@ pub async fn message_send(
// Redis returns positive integers for valid TTLs // Redis returns positive integers for valid TTLs
if ttl > 0 { if ttl > 0 {
EventV1::UserSlowmodes {
slowmodes: vec![ChannelSlowmode {
channel_id: channel_id.to_string(),
duration: *channel_slowmode,
retry_after: ttl as u64,
}],
}
.private(user.id.clone())
.await;
return Err(create_error!(InSlowmode { return Err(create_error!(InSlowmode {
retry_after: ttl as u64 retry_after: ttl as u64
})); }));
} }
} else {
EventV1::UserSlowmodes {
slowmodes: vec![ChannelSlowmode {
channel_id: channel_id.to_string(),
duration: *channel_slowmode,
retry_after: *channel_slowmode,
}],
}
.private(user.id.clone())
.await;
} }
} }
// If Redis connection fails, just skip the slowmode check // If Redis connection fails, just skip the slowmode check
@@ -143,12 +111,8 @@ pub async fn message_send(
// Disallow mentions for new users (TRUST-0: <12 hours age) in public servers // Disallow mentions for new users (TRUST-0: <12 hours age) in public servers
let allow_mentions = if let Some(server) = query.server_ref() { let allow_mentions = if let Some(server) = query.server_ref() {
if server.discoverable { if server.discoverable {
(ulid::Ulid::from_string(&user.id) (Utc::now() - ulid::Ulid::from_string(&user.id).unwrap().datetime())
.unwrap() >= Duration::hours(12)
.datetime()
.elapsed()
.expect("Time went backwards"))
>= Duration::from_hours(12)
} else { } else {
true true
} }
@@ -1,212 +0,0 @@
use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, EmojiParent, PartialEmoji, User,
};
use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
use validator::Validate;
/// # Edit Emoji
///
/// Edit an emoji by its id.
#[openapi(tag = "Emojis")]
#[patch("/emoji/<emoji_id>", data = "<data>")]
pub async fn edit_emoji(
db: &State<Database>,
user: User,
emoji_id: Reference<'_>,
data: Json<v0::DataEditEmoji>,
) -> Result<Json<v0::Emoji>> {
let data = data.into_inner();
data.validate().map_err(|error| {
create_error!(FailedValidation {
error: error.to_string()
})
})?;
let mut emoji = emoji_id.as_emoji(db).await?;
match &emoji.parent {
EmojiParent::Server { id } => {
let server = db.fetch_server(id.as_str()).await?;
let mut query = DatabasePermissionQuery::new(db, &user).server(&server);
calculate_server_permissions(&mut query)
.await
.throw_if_lacking_channel_permission(ChannelPermission::ManageCustomisation)?;
}
EmojiParent::Detached => return Err(create_error!(NotAuthenticated)),
}
if data.name.is_none() {
return Ok(Json(emoji.into()));
}
let partial = PartialEmoji { name: data.name };
emoji.update(db, partial).await?;
Ok(Json(emoji.into()))
}
#[cfg(test)]
mod test {
use crate::util::test::TestHarness;
use revolt_database::{Emoji, EmojiParent, Member};
use revolt_models::v0;
use rocket::http::{ContentType, Header, Status};
use ulid::Ulid;
#[rocket::async_test]
async fn edit_emoji_name_as_creator() {
let harness = TestHarness::new().await;
let (_, session, user) = harness.new_user().await;
let (server, _) = harness.new_server(&user).await;
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Server {
id: server.id.clone(),
},
creator_id: user.id.clone(),
name: "initial_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new("x-session-token", session.token.to_string()))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("renamed_emoji".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::Ok);
let edited: v0::Emoji = response.into_json().await.expect("`Emoji`");
assert_eq!(edited.name, "renamed_emoji");
}
#[rocket::async_test]
async fn reject_invalid_emoji_name() {
let harness = TestHarness::new().await;
let (_, session, user) = harness.new_user().await;
let (server, _) = harness.new_server(&user).await;
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Server {
id: server.id.clone(),
},
creator_id: user.id.clone(),
name: "valid_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new("x-session-token", session.token.to_string()))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("Invalid Name".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::BadRequest);
}
#[rocket::async_test]
async fn reject_edit_for_detached_emoji() {
let harness = TestHarness::new().await;
let (_, session, user) = harness.new_user().await;
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Detached,
creator_id: user.id.clone(),
name: "detached_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new("x-session-token", session.token.to_string()))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("should_not_apply".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::Unauthorized);
}
#[rocket::async_test]
async fn reject_edit_for_creator_without_manage_customisation() {
let harness = TestHarness::new().await;
let (_, _, owner) = harness.new_user().await;
let (_, creator_session, creator) = harness.new_user().await;
let (server, _) = harness.new_server(&owner).await;
Member::create(&harness.db, &server, &creator, None)
.await
.expect("`Member` created");
let emoji_id = Ulid::new().to_string();
let emoji = Emoji {
id: emoji_id.clone(),
parent: EmojiParent::Server {
id: server.id.clone(),
},
creator_id: creator.id.clone(),
name: "member_uploaded_name".to_string(),
animated: false,
nsfw: false,
};
emoji.create(&harness.db).await.expect("`Emoji` created");
let response = harness
.client
.patch(format!("/custom/emoji/{emoji_id}"))
.header(Header::new(
"x-session-token",
creator_session.token.to_string(),
))
.header(ContentType::JSON)
.body(
json!(v0::DataEditEmoji {
name: Some("renamed_without_permission".to_string()),
})
.to_string(),
)
.dispatch()
.await;
assert_eq!(response.status(), Status::Forbidden);
}
}
@@ -3,14 +3,12 @@ use rocket::Route;
mod emoji_create; mod emoji_create;
mod emoji_delete; mod emoji_delete;
mod emoji_edit;
mod emoji_fetch; mod emoji_fetch;
pub fn routes() -> (Vec<Route>, OpenApi) { pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![ openapi_get_routes_spec![
emoji_create::create_emoji, emoji_create::create_emoji,
emoji_delete::delete_emoji, emoji_delete::delete_emoji,
emoji_edit::edit_emoji,
emoji_fetch::fetch_emoji emoji_fetch::fetch_emoji
] ]
} }
-21
View File
@@ -57,8 +57,6 @@ pub struct RevoltFeatures {
pub livekit: VoiceFeature, pub livekit: VoiceFeature,
/// Limits /// Limits
pub limits: LimitsConfig, pub limits: LimitsConfig,
/// Legal links
pub legal_links: LegalLinks,
} }
/// # Limits For Users /// # Limits For Users
@@ -72,17 +70,6 @@ pub struct LimitsConfig {
pub default: UserLimits, pub default: UserLimits,
} }
/// # Legal links
#[derive(Serialize, JsonSchema, Debug)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
/// # Global limits /// # Global limits
#[derive(Serialize, JsonSchema, Debug)] #[derive(Serialize, JsonSchema, Debug)]
pub struct GlobalLimits { pub struct GlobalLimits {
@@ -105,8 +92,6 @@ pub struct GlobalLimits {
/// restrict server creation to these users. /// restrict server creation to these users.
/// if blank, all users can create servers /// if blank, all users can create servers
pub restrict_server_creation: Vec<String>, pub restrict_server_creation: Vec<String>,
/// New user hours
new_user_hours: i64,
} }
/// # User Limits /// # User Limits
@@ -246,16 +231,10 @@ pub async fn root() -> Result<Json<RevoltConfig>> {
.limits .limits
.global .global
.restrict_server_creation, .restrict_server_creation,
new_user_hours: config.features.limits.global.new_user_hours as i64,
}, },
new_user: UserLimits::from_feature_limits(config.features.limits.new_user), new_user: UserLimits::from_feature_limits(config.features.limits.new_user),
default: UserLimits::from_feature_limits(config.features.limits.default), default: UserLimits::from_feature_limits(config.features.limits.default),
}, },
legal_links: LegalLinks {
terms_of_service: config.features.legal_links.terms_of_service,
privacy_policy: config.features.legal_links.privacy_policy,
guidelines: config.features.legal_links.guidelines,
},
}, },
ws: config.hosts.events, ws: config.hosts.events,
app: config.hosts.app, app: config.hosts.app,
+3 -17
View File
@@ -1,7 +1,7 @@
use revolt_database::{ use revolt_database::{
util::{permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
voice::{sync_voice_permissions, VoiceClient}, voice::{sync_voice_permissions, VoiceClient},
Database, File, PartialRole, User, Database, PartialRole, User
}; };
use revolt_models::v0; use revolt_models::v0;
use revolt_permissions::{calculate_server_permissions, ChannelPermission}; use revolt_permissions::{calculate_server_permissions, ChannelPermission};
@@ -47,27 +47,14 @@ pub async fn edit(
name, name,
colour, colour,
hoist, hoist,
icon,
remove, remove,
.. ..
} = data; } = data;
if remove.contains(&v0::FieldsRole::Icon) {
if let Some(existing_icon) = &role.icon {
db.mark_attachment_as_deleted(&existing_icon.id).await?;
}
}
let mut final_icon = None;
if let Some(icon_id) = icon {
final_icon = Some(File::use_role_icon(db, &icon_id, &role_id, &user.id).await?);
}
let partial = PartialRole { let partial = PartialRole {
name, name,
colour, colour,
hoist, hoist,
icon: final_icon,
..Default::default() ..Default::default()
}; };
@@ -82,9 +69,8 @@ pub async fn edit(
for channel_id in &server.channels { for channel_id in &server.channels {
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?; let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)) sync_voice_permissions(db, voice_client, &channel, Some(&server), Some(&role_id)).await?;
.await?; };
}
Ok(Json(role.into())) Ok(Json(role.into()))
} else { } else {
+6 -10
View File
@@ -1,6 +1,6 @@
use revolt_database::{ use revolt_database::{
util::{acker, permissions::DatabasePermissionQuery, reference::Reference}, util::{permissions::DatabasePermissionQuery, reference::Reference},
Database, User, AMQP, Database, User,
}; };
use revolt_permissions::PermissionQuery; use revolt_permissions::PermissionQuery;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
@@ -12,12 +12,7 @@ use rocket_empty::EmptyResponse;
/// Mark all channels in a server as read. /// Mark all channels in a server as read.
#[openapi(tag = "Server Information")] #[openapi(tag = "Server Information")]
#[put("/<target>/ack")] #[put("/<target>/ack")]
pub async fn ack( pub async fn ack(db: &State<Database>, user: User, target: Reference<'_>) -> Result<EmptyResponse> {
db: &State<Database>,
amqp: &State<AMQP>,
user: User,
target: Reference<'_>,
) -> Result<EmptyResponse> {
if user.bot.is_some() { if user.bot.is_some() {
return Err(create_error!(IsBot)); return Err(create_error!(IsBot));
} }
@@ -28,6 +23,7 @@ pub async fn ack(
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
acker::ack_server(&user, &server, db, amqp).await?; db.acknowledge_channels(&user.id, &server.channels)
Ok(EmptyResponse) .await
.map(|_| EmptyResponse)
} }
+3 -7
View File
@@ -1,23 +1,19 @@
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
use rocket::Route; use rocket::Route;
use revolt_rocket_okapi::revolt_okapi::openapi3::OpenApi;
mod webhook_delete; mod webhook_delete;
mod webhook_delete_message;
mod webhook_delete_token; mod webhook_delete_token;
mod webhook_edit; mod webhook_edit;
mod webhook_edit_message;
mod webhook_edit_token; mod webhook_edit_token;
mod webhook_execute; mod webhook_execute;
mod webhook_execute_github;
mod webhook_fetch;
mod webhook_fetch_token; mod webhook_fetch_token;
mod webhook_fetch;
mod webhook_execute_github;
pub fn routes() -> (Vec<Route>, OpenApi) { pub fn routes() -> (Vec<Route>, OpenApi) {
openapi_get_routes_spec![ openapi_get_routes_spec![
webhook_delete_message::webhook_delete_message,
webhook_delete_token::webhook_delete_token, webhook_delete_token::webhook_delete_token,
webhook_delete::webhook_delete, webhook_delete::webhook_delete,
webhook_edit_message::webhook_edit_message,
webhook_edit_token::webhook_edit_token, webhook_edit_token::webhook_edit_token,
webhook_edit::webhook_edit, webhook_edit::webhook_edit,
webhook_execute_github::webhook_execute_github, webhook_execute_github::webhook_execute_github,
@@ -1,27 +0,0 @@
use revolt_database::{util::reference::Reference, Database};
use revolt_result::{create_error, Result};
use rocket::State;
use rocket_empty::EmptyResponse;
/// # Deletes a webhook message
///
/// Deletes a message sent by a webhook
#[openapi(tag = "Webhooks")]
#[delete("/<webhook_id>/<token>/<message_id>")]
pub async fn webhook_delete_message(
db: &State<Database>,
webhook_id: Reference<'_>,
token: String,
message_id: Reference<'_>,
) -> Result<EmptyResponse> {
let webhook = webhook_id.as_webhook(db).await?;
webhook.assert_token(&token)?;
let message = message_id.as_message(db).await?;
if message.author != webhook.id {
return Err(create_error!(CannotDeleteMessage));
}
message.delete(db).await.map(|_| EmptyResponse)
}
@@ -1,84 +0,0 @@
use iso8601_timestamp::Timestamp;
use revolt_config::config;
use revolt_database::{
tasks::process_embeds::queue, util::reference::Reference, Database, Message, PartialMessage,
};
use revolt_models::v0::{self, DataEditMessage, Embed};
use revolt_models::validator::Validate;
use revolt_result::{create_error, Result};
use rocket::{serde::json::Json, State};
/// # Edits a webhook message
///
/// Edits a message sent by a webhook
#[openapi(tag = "Webhooks")]
#[patch("/<webhook_id>/<token>/<message_id>", data = "<data>")]
pub async fn webhook_edit_message(
db: &State<Database>,
webhook_id: Reference<'_>,
token: String,
message_id: Reference<'_>,
data: Json<DataEditMessage>,
) -> Result<Json<v0::Message>> {
let edit = data.into_inner();
edit.validate().map_err(|error| {
create_error!(FailedValidation {
error: error.to_string()
})
})?;
Message::validate_sum(
&edit.content,
edit.embeds.as_deref().unwrap_or_default(),
config().await.features.limits.default.message_length,
)?;
let webhook = webhook_id.as_webhook(db).await?;
webhook.assert_token(&token)?;
let mut message = message_id.as_message(db).await?;
if message.author != webhook.id {
return Err(create_error!(CannotEditMessage));
}
message.edited = Some(Timestamp::now_utc());
let mut partial = PartialMessage {
edited: message.edited,
..Default::default()
};
// 1. Handle content update
if let Some(content) = &edit.content {
partial.content = Some(content.clone());
}
// 2. Clear any auto generated embeds
let mut new_embeds = vec![];
if let Some(embeds) = &message.embeds {
for embed in embeds {
if let Embed::Text(embed) = embed {
new_embeds.push(Embed::Text(embed.clone()))
}
}
}
// 3. Replace if we are given new embeds
if let Some(embeds) = edit.embeds {
new_embeds.clear();
for embed in embeds {
new_embeds.push(message.create_embed(db, embed).await?);
}
}
partial.embeds = Some(new_embeds);
message.update(db, partial, vec![]).await?;
// Queue up a task for processing embeds
if let Some(content) = edit.content {
queue(message.channel.to_string(), message.id.to_string(), content).await;
}
Ok(Json(message.into_model(None, None)))
}
+16 -2
View File
@@ -4,7 +4,7 @@ use authifier::{
}; };
use futures::StreamExt; use futures::StreamExt;
use rand::Rng; use rand::Rng;
use redis_kiss::{redis::aio::PubSub}; use redis_kiss::redis::aio::PubSub;
use revolt_database::{ use revolt_database::{
events::client::EventV1, Channel, Database, Member, Message, PartialRole, Server, User, AMQP, events::client::EventV1, Channel, Database, Member, Message, PartialRole, Server, User, AMQP,
}; };
@@ -25,6 +25,8 @@ pub struct TestHarness {
impl TestHarness { impl TestHarness {
pub async fn new() -> TestHarness { pub async fn new() -> TestHarness {
let config = revolt_config::config().await;
let client = Client::tracked(crate::web().await) let client = Client::tracked(crate::web().await)
.await .await
.expect("valid rocket instance"); .expect("valid rocket instance");
@@ -47,7 +49,19 @@ impl TestHarness {
.expect("`Authifier`") .expect("`Authifier`")
.clone(); .clone();
let amqp = AMQP::new_auto().await; let connection = amqprs::connection::Connection::open(
&amqprs::connection::OpenConnectionArguments::new(
&config.rabbit.host,
config.rabbit.port,
&config.rabbit.username,
&config.rabbit.password,
),
)
.await
.unwrap();
let channel = connection.open_channel(None).await.unwrap();
let amqp = AMQP::new(connection, channel);
TestHarness { TestHarness {
client, client,
+44 -36
View File
@@ -1,64 +1,72 @@
[package] [package]
name = "revolt-autumn" name = "revolt-autumn"
version = "0.13.7" version = "0.12.0"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
publish = false publish = false
[dependencies] [dependencies]
# ID generation # ID generation
ulid = { workspace = true } ulid = "1.1.3"
nanoid = { workspace = true } nanoid = "0.4.0"
# Media processing # Media processing
webp = { workspace = true } webp = "0.3.0"
sha2 = { workspace = true } sha2 = "0.10.8"
jxl-oxide = { workspace = true } jxl-oxide = "0.8.1"
kamadak-exif = { workspace = true } kamadak-exif = "0.5.4"
# revolt_little_exif = "0.5.1" # revolt_little_exif = "0.5.1"
image = { workspace = true } image = { version = "0.25.2" } # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = { workspace = true } thumbhash = "0.1.0"
lcms2 = { workspace = true }
# File processing # File processing
revolt_clamav-client = { workspace = true } revolt_clamav-client = { version = "0.1.5" }
simdutf8 = { workspace = true, features = ["aarch64_neon"] } simdutf8 = { version = "0.1.4", features = ["aarch64_neon"] }
# Content type processing # Content type processing
infer = { workspace = true } infer = "0.16.0"
ffprobe = { workspace = true } ffprobe = "0.4.0"
imagesize = { workspace = true } imagesize = "0.13.0"
# Utility # Utility
lazy_static = { workspace = true } lazy_static = "1.5.0"
moka = { workspace = true, features = ["future"] } moka = { version = "0.12.8", features = ["future"] }
url-escape = { workspace = true }
# Serialisation # Serialisation
strum_macros = { workspace = true } strum_macros = "0.26.4"
serde_json = { workspace = true } serde_json = "1.0.68"
serde = { workspace = true } serde = { version = "1.0", features = ["derive"] }
# Async runtime # Async runtime
tokio = { workspace = true, features = ["full"] } tokio = { version = "1.0", features = ["full"] }
# Logging
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
# Core crates # Core crates
revolt-files = { workspace = true } revolt-files = { version = "0.12.0", path = "../../core/files" }
revolt-config = { workspace = true } revolt-config = { version = "0.12.0", path = "../../core/config" }
revolt-database = { workspace = true, features = ["axum-impl"] } revolt-database = { version = "0.12.0", path = "../../core/database", features = [
revolt-result = { workspace = true, features = ["utoipa", "axum"] } "axum-impl",
revolt-ratelimits = { workspace = true, features = ["axum"] } ] }
revolt-result = { version = "0.12.0", path = "../../core/result", features = [
"utoipa",
"axum",
] }
revolt-ratelimits = { version = "0.12.0", path = "../../core/ratelimits", features = ["axum"] }
# Axum / web server # Axum / web server
tempfile = { workspace = true } tempfile = "3.12.0"
axum-macros = { workspace = true } axum-macros = { workspace = true }
axum_typed_multipart = { workspace = true } axum_typed_multipart = { workspace = true }
axum = { workspace = true, features = ["multipart"] } axum = { workspace = true }
tower-http = { workspace = true, features = ["cors"] } tower-http = { workspace = true }
# OpenAPI & documentation generation # OpenAPI & documentation generation
utoipa-scalar = { workspace = true, features = ["axum"] } utoipa-scalar = { version = "0.1.0", features = ["axum"] }
utoipa = { workspace = true, features = ["axum_extras", "ulid"] } utoipa = { version = "4.2.3", features = ["axum_extras", "ulid"] }
# OpenTelemetry
tracing = { workspace = true }
tracing-subscriber = { workspace = true }
opentelemetry = { workspace = true }
opentelemetry_sdk = { workspace = true }
opentelemetry-otlp = { workspace = true }
opentelemetry-appender-tracing = { workspace = true }
+10 -83
View File
@@ -1,5 +1,5 @@
use std::{ use std::{
io::{Cursor, Read, Write}, io::{Cursor, Read},
time::Duration, time::Duration,
}; };
@@ -15,23 +15,18 @@ use lazy_static::lazy_static;
use revolt_config::{config, report_internal_error}; use revolt_config::{config, report_internal_error};
use revolt_database::{iso8601_timestamp::Timestamp, Database, FileHash, Metadata, User}; use revolt_database::{iso8601_timestamp::Timestamp, Database, FileHash, Metadata, User};
use revolt_files::{ use revolt_files::{
create_thumbnail, decode_image, fetch_from_s3, is_animated, upload_to_s3, create_thumbnail, decode_image, fetch_from_s3, upload_to_s3, AUTHENTICATION_TAG_SIZE_BYTES,
AUTHENTICATION_TAG_SIZE_BYTES,
}; };
use revolt_result::{create_error, Error, Result, ToRevoltError}; use revolt_result::{create_error, Error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::Digest; use sha2::Digest;
use tempfile::NamedTempFile; use tempfile::NamedTempFile;
use tokio::time::Instant; use tokio::time::Instant;
use tower_http::cors::{AllowHeaders, Any, CorsLayer}; use tower_http::cors::{AllowHeaders, Any, CorsLayer};
use url_escape::encode_component;
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::{ use crate::{exif::strip_metadata, metadata::generate_metadata, mime_type::determine_mime_type, AppState};
exif::strip_metadata, metadata::generate_metadata, mime_type::determine_mime_type, AppState,
};
/// Build the API router
pub async fn router() -> Router<AppState> { pub async fn router() -> Router<AppState> {
let config = config().await; let config = config().await;
@@ -62,8 +57,6 @@ pub async fn router() -> Router<AppState> {
} }
lazy_static! { lazy_static! {
/// Short-lived file cache to allow us to populate different CDN regions without increasing bandwidth to S3 provider
/// Uploads will also be stored here to prevent immediately queued downloads from doing the entire round-trip
static ref S3_CACHE: moka::future::Cache<String, Result<Vec<u8>>> = moka::future::Cache::builder() static ref S3_CACHE: moka::future::Cache<String, Result<Vec<u8>>> = moka::future::Cache::builder()
.weigher(|_key, value: &Result<Vec<u8>>| -> u32 { .weigher(|_key, value: &Result<Vec<u8>>| -> u32 {
std::mem::size_of::<Result<Vec<u8>>>() as u32 + if let Ok(vec) = value { std::mem::size_of::<Result<Vec<u8>>>() as u32 + if let Ok(vec) = value {
@@ -80,7 +73,6 @@ lazy_static! {
.build(); .build();
} }
/// Retrieve hash information and file data by given hash
async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> { async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> {
if let Some(data) = S3_CACHE.get(&hash.id).await { if let Some(data) = S3_CACHE.get(&hash.id).await {
data data
@@ -91,17 +83,15 @@ async fn retrieve_file_by_hash(hash: &FileHash) -> Result<Vec<u8>> {
} }
} }
/// Successful root response
#[derive(Serialize, Debug, ToSchema)] #[derive(Serialize, Debug, ToSchema)]
pub struct RootResponse { pub struct RootResponse {
autumn: &'static str, autumn: &'static str,
version: &'static str, version: &'static str,
} }
/// Capture crate version from Cargo
static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION"); static CRATE_VERSION: &str = env!("CARGO_PKG_VERSION");
/// Root response from service /// Get information about the service
#[utoipa::path( #[utoipa::path(
get, get,
path = "/", path = "/",
@@ -116,7 +106,6 @@ async fn root() -> Json<RootResponse> {
}) })
} }
/// Empty handler for OPTIONS routes
async fn options() {} async fn options() {}
/// Available tags to upload to /// Available tags to upload to
@@ -180,28 +169,19 @@ async fn upload_file(
Path(tag): Path<Tag>, Path(tag): Path<Tag>,
TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>, TypedMultipart(UploadPayload { mut file }): TypedMultipart<UploadPayload>,
) -> Result<Json<UploadResponse>> { ) -> Result<Json<UploadResponse>> {
// Fetch configuration let now = Instant::now();
let config = config().await; let config = config().await;
// Keep track of processing time
let now = Instant::now();
// Extract the filename, or give it a generic name
let filename = file.metadata.file_name.unwrap_or("unnamed-file".to_owned()); let filename = file.metadata.file_name.unwrap_or("unnamed-file".to_owned());
// Load file to memory
let mut buf = Vec::<u8>::new(); let mut buf = Vec::<u8>::new();
report_internal_error!(file.contents.read_to_end(&mut buf))?; report_internal_error!(file.contents.read_to_end(&mut buf))?;
// Take note of original file size
let original_file_size = buf.len(); let original_file_size = buf.len();
// Ensure the file is not empty
if original_file_size < config.files.limit.min_file_size { if original_file_size < config.files.limit.min_file_size {
return Err(create_error!(FileTooSmall)); return Err(create_error!(FileTooSmall));
} }
// Get user's file upload limits
let limits = user.limits().await; let limits = user.limits().await;
let size_limit = *limits let size_limit = *limits
.file_upload_size_limit .file_upload_size_limit
@@ -212,24 +192,19 @@ async fn upload_file(
return Err(create_error!(FileTooLarge { max: size_limit })); return Err(create_error!(FileTooLarge { max: size_limit }));
} }
// Generate sha256 hash
let original_hash = { let original_hash = {
let mut hasher = sha2::Sha256::new(); let mut hasher = sha2::Sha256::new();
hasher.update(&buf); hasher.update(&buf);
hasher.finalize() hasher.finalize()
}; };
// Generate an ID for this file
let id = if matches!(tag, Tag::emojis) { let id = if matches!(tag, Tag::emojis) {
ulid::Ulid::new().to_string() ulid::Ulid::new().to_string()
} else { } else {
nanoid::nanoid!(42) nanoid::nanoid!(42)
}; };
// Determine the mime type for the file
let mime_type = determine_mime_type(&mut file.contents, &buf, &filename); let mime_type = determine_mime_type(&mut file.contents, &buf, &filename);
// Check blocklist for mime type
if config if config
.files .files
.blocked_mime_types .blocked_mime_types
@@ -239,15 +214,11 @@ async fn upload_file(
return Err(create_error!(FileTypeNotAllowed)); return Err(create_error!(FileTypeNotAllowed));
} }
// Determine metadata for the file
let metadata = generate_metadata(&file.contents, mime_type); let metadata = generate_metadata(&file.contents, mime_type);
// Block non-images for non-attachment uploads
if !matches!(tag, Tag::attachments) && !matches!(metadata, Metadata::Image { .. }) { if !matches!(tag, Tag::attachments) && !matches!(metadata, Metadata::Image { .. }) {
return Err(create_error!(FileTypeNotAllowed)); return Err(create_error!(FileTypeNotAllowed));
} }
// Find an existing hash and use that if possible
let file_hash_exists = if let Ok(file_hash) = db let file_hash_exists = if let Ok(file_hash) = db
.fetch_attachment_hash(&format!("{original_hash:02x}")) .fetch_attachment_hash(&format!("{original_hash:02x}"))
.await .await
@@ -270,10 +241,8 @@ async fn upload_file(
false false
}; };
// Strip metadata
let (buf, metadata) = strip_metadata(file.contents, buf, metadata, mime_type).await?; let (buf, metadata) = strip_metadata(file.contents, buf, metadata, mime_type).await?;
// Virus scan files if ClamAV is configured
if matches!(metadata, Metadata::File) if matches!(metadata, Metadata::File)
&& (config.files.scan_mime_types.is_empty() && (config.files.scan_mime_types.is_empty()
|| config.files.scan_mime_types.iter().any(|v| v == mime_type)) || config.files.scan_mime_types.iter().any(|v| v == mime_type))
@@ -282,7 +251,6 @@ async fn upload_file(
return Err(create_error!(InternalError)); return Err(create_error!(InternalError));
} }
// Print file information for debug purposes
let new_file_size = buf.len() + AUTHENTICATION_TAG_SIZE_BYTES; let new_file_size = buf.len() + AUTHENTICATION_TAG_SIZE_BYTES;
let processed_hash = { let processed_hash = {
let mut hasher = sha2::Sha256::new(); let mut hasher = sha2::Sha256::new();
@@ -294,7 +262,6 @@ async fn upload_file(
tracing::info!("Received file {filename}\nOriginal hash: {original_hash:02x}\nOriginal size: {original_file_size} bytes\nMime type: {mime_type}\nMetadata: {metadata:?}\nProcessed file size: {new_file_size} bytes ({:.2}%).\nProcessed hash: {processed_hash:02x}\nProcessing took {time_to_process:?}", process_ratio * 100.0); tracing::info!("Received file {filename}\nOriginal hash: {original_hash:02x}\nOriginal size: {original_file_size} bytes\nMime type: {mime_type}\nMetadata: {metadata:?}\nProcessed file size: {new_file_size} bytes ({:.2}%).\nProcessed hash: {processed_hash:02x}\nProcessing took {time_to_process:?}", process_ratio * 100.0);
// Create hash entry in database
let file_hash = FileHash { let file_hash = FileHash {
id: format!("{original_hash:02x}"), id: format!("{original_hash:02x}"),
processed_hash: format!("{processed_hash:02x}"), processed_hash: format!("{processed_hash:02x}"),
@@ -310,21 +277,17 @@ async fn upload_file(
size: new_file_size as isize, size: new_file_size as isize,
}; };
// Add attachment hash if it doesn't exist
if !file_hash_exists { if !file_hash_exists {
db.insert_attachment_hash(&file_hash).await?; db.insert_attachment_hash(&file_hash).await?;
} }
// Upload the file to S3 and commit nonce to database
let upload_start = Instant::now(); let upload_start = Instant::now();
let nonce = upload_to_s3(&file_hash.bucket_id, &file_hash.id, &buf).await?; let nonce = upload_to_s3(&file_hash.bucket_id, &file_hash.id, &buf).await?;
db.set_attachment_hash_nonce(&file_hash.id, &nonce).await?; db.set_attachment_hash_nonce(&file_hash.id, &nonce).await?;
// Debug information
let time_to_upload = Instant::now() - upload_start; let time_to_upload = Instant::now() - upload_start;
tracing::info!("Took {time_to_upload:?} to upload {new_file_size} bytes to S3."); tracing::info!("Took {time_to_upload:?} to upload {new_file_size} bytes to S3.");
// Finally, create the file and return its ID
let tag: &'static str = tag.into(); let tag: &'static str = tag.into();
db.insert_attachment(&file_hash.into_file(id.clone(), tag.to_owned(), filename, user.id)) db.insert_attachment(&file_hash.into_file(id.clone(), tag.to_owned(), filename, user.id))
.await?; .await?;
@@ -372,44 +335,19 @@ async fn fetch_preview(
let tag_str: &'static str = tag.clone().into(); let tag_str: &'static str = tag.clone().into();
let file = db.fetch_attachment(tag_str, &file_id).await?; let file = db.fetch_attachment(tag_str, &file_id).await?;
// Ignore deleted files
if file.deleted.is_some_and(|v| v) { if file.deleted.is_some_and(|v| v) {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
// Ignore files that haven't been attached
if file.used_for.is_none() { if file.used_for.is_none() {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
let hash = file.as_hash(&db).await?; let hash = file.as_hash(&db).await?;
let mut data = None; let is_animated = matches!(hash.metadata, Metadata::Image { animated: true, .. });
// If animated is unset, check the file contents to see if it is animated and update the filehash // Process GIFs if avatar or icon
let is_animated = match &hash.metadata {
Metadata::Image {
animated: Some(value),
..
} => *value,
Metadata::Image { animated: None, .. } => {
let file_data = retrieve_file_by_hash(&hash).await?;
let mut named_file = NamedTempFile::new().to_internal_error()?;
named_file.write(&file_data).to_internal_error()?;
data = Some(file_data);
// If it fails for some reason, set it to not be animated
let animated = is_animated(&named_file, &hash.content_type).unwrap_or(false);
db.set_attachment_hash_animated(&hash.id, animated).await?;
animated
}
_ => false,
};
// Only process image files and don't process GIFs if not avatar or icon
if !matches!(hash.metadata, Metadata::Image { .. }) if !matches!(hash.metadata, Metadata::Image { .. })
|| (is_animated && !matches!(tag, Tag::avatars | Tag::icons)) || (is_animated && !matches!(tag, Tag::avatars | Tag::icons))
{ {
@@ -418,14 +356,8 @@ async fn fetch_preview(
); );
} }
// Original image data let data = retrieve_file_by_hash(&hash).await?;
let data = if let Some(data) = data {
data
} else {
retrieve_file_by_hash(&hash).await?
};
// Read image and create thumbnail
let data = create_thumbnail( let data = create_thumbnail(
decode_image(&mut Cursor::new(data), &file.content_type)?, decode_image(&mut Cursor::new(data), &file.content_type)?,
tag_str, tag_str,
@@ -467,23 +399,18 @@ async fn fetch_file(
let tag: &'static str = tag.clone().into(); let tag: &'static str = tag.clone().into();
let file = db.fetch_attachment(tag, &file_id).await?; let file = db.fetch_attachment(tag, &file_id).await?;
// Ignore deleted files
if file.deleted.is_some_and(|v| v) { if file.deleted.is_some_and(|v| v) {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
// Ignore files that haven't been attached
if file.used_for.is_none() { if file.used_for.is_none() {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
} }
// Ensure filename is correct
if file_name != file.filename { if file_name != file.filename {
if file_name == "original" { if file_name == "original" {
let safe_filename = encode_component(&file.filename);
return Ok( return Ok(
Redirect::permanent(&format!("/{tag}/{file_id}/{}", safe_filename)).into_response(), Redirect::permanent(&format!("/{tag}/{file_id}/{}", file.filename)).into_response(),
); );
} }
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more