Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Makles
95db641f8f feat: initialise Discord bridge crate 2023-04-21 22:29:17 +01:00
717 changed files with 19386 additions and 194391 deletions

View File

@@ -1,2 +0,0 @@
[profile.default]
slow-timeout = { period = "5s", terminate-after = 10 }

79
.env.example Normal file
View File

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

View File

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

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
...

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

@@ -0,0 +1,36 @@
name: "CLA Assistant"
on:
issue_comment:
types: [created]
pull_request_target:
types: [opened,closed,synchronize]
jobs:
CLAssistant:
runs-on: ubuntu-latest
steps:
- name: "CLA Assistant"
if: (github.event.comment.body == 'recheck' || github.event.comment.body == 'I have read the CLA Document and I hereby sign the CLA') || github.event_name == 'pull_request_target'
# Beta Release
uses: cla-assistant/github-action@v2.1.3-beta
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# the below token should have repo scope and must be manually added by you in the repository's secret
PERSONAL_ACCESS_TOKEN : ${{ secrets.PAT }}
with:
path-to-signatures: 'signatures/version1/cla.json'
path-to-document: 'https://github.com/revoltchat/cla/blob/master/CLA.md' # e.g. a CLA or a DCO document
# branch should not be protected
branch: 'master'
allowlist: insertish,bot*
#below are the optional inputs - If the optional inputs are not given, then default values will be taken
remote-organization-name: revoltchat
remote-repository-name: cla
create-file-commit-message: 'cla(create): creating file for storing CLA Signatures'
signed-commit-message: 'cla(sign): $contributorName has signed the CLA in #$pullRequestNo'
#custom-notsigned-prcomment: 'pull request comment with Introductory message to ask new contributors to sign'
#custom-pr-sign-comment: 'The signature to be committed in order to sign the CLA'
#custom-allsigned-prcomment: 'pull request comment when all contributors has signed, defaults to **CLA Assistant Lite bot** All Contributors have signed the CLA.'
#lock-pullrequest-aftermerge: false - if you don't want this bot to automatically lock the pull request after merging (default - true)
#use-dco-flag: true - If you are using DCO instead of CLA

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

View File

@@ -2,24 +2,32 @@ name: Docker Test & Publish
on:
push:
branches:
- "master"
tags:
- "*"
paths-ignore:
- ".github/**"
- "!.github/workflows/docker.yml"
- ".vscode/**"
- ".gitignore"
- "LICENSE"
- "README"
pull_request:
branches:
- "master"
paths:
- "Dockerfile"
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: ${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
base:
name: Test base image build (fork)
runs-on: arc-runner-set
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
runs-on: ubuntu-latest
name: Build base image
steps:
# Configure build environment
- name: Checkout
@@ -27,21 +35,33 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
# Build the image
# Authenticate with GHCR
- name: Login to Github Container Registry
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@v2
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: docker/build-push-action@v4
with:
context: .
push: false
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
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
publish:
runs-on: arc-runner-set
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
name: Publish Docker images
needs: [base]
runs-on: ubuntu-latest
if: github.event_name != 'pull_request'
strategy:
matrix:
project: [delta, bonfire]
name: Build ${{ matrix.project }} image
steps:
# Configure build environment
- name: Checkout
@@ -50,6 +70,12 @@ jobs:
uses: docker/setup-buildx-action@v2
# Authenticate with Docker Hub and GHCR
- name: Login to DockerHub
uses: docker/login-action@v2
with:
registry: docker.io
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Github Container Registry
uses: docker/login-action@v2
with:
@@ -57,174 +83,42 @@ jobs:
username: ${{ github.actor }}
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
- name: Build base image
uses: docker/build-push-action@v4
# Resolve the correct project
- uses: kanga333/variable-mapper@master
id: export
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
cache-from: type=gha,scope=buildx-base-multi-arch
cache-to: type=gha,scope=buildx-base-multi-arch,mode=max
key: "${{ matrix.project }}"
map: |
{
"delta": {
"path": "crates/delta",
"tag": "${{ github.repository_owner }}/server"
},
"bonfire": {
"path": "crates/bonfire",
"tag": "${{ github.repository_owner }}/bonfire"
}
}
export_to: output
# stoatchat/api
# Configure metadata
- name: Docker meta
id: meta-delta
id: meta
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/api
docker.io/${{ steps.export.outputs.tag }}
ghcr.io/${{ steps.export.outputs.tag }}
# Build crate image
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/delta/Dockerfile
tags: ${{ steps.meta-delta.outputs.tags }}
file: ${{ steps.export.outputs.path }}/Dockerfile
tags: ${{ steps.meta.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-delta.outputs.labels }}
# stoatchat/events
- name: Docker meta
id: meta-bonfire
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/events
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/bonfire/Dockerfile
tags: ${{ steps.meta-bonfire.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-bonfire.outputs.labels }}
# stoatchat/file-server
- name: Docker meta
id: meta-autumn
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/file-server
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/services/autumn/Dockerfile
tags: ${{ steps.meta-autumn.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-autumn.outputs.labels }}
# stoatchat/proxy
- name: Docker meta
id: meta-january
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/proxy
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/services/january/Dockerfile
tags: ${{ steps.meta-january.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-january.outputs.labels }}
# stoatchat/gifbox
- name: Docker meta
id: meta-gifbox
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/gifbox
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/services/gifbox/Dockerfile
tags: ${{ steps.meta-gifbox.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-gifbox.outputs.labels }}
# stoatchat/crond
- name: Docker meta
id: meta-crond
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/crond
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/daemons/crond/Dockerfile
tags: ${{ steps.meta-crond.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-crond.outputs.labels }}
# stoatchat/pushd
- name: Docker meta
id: meta-pushd
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/pushd
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/daemons/pushd/Dockerfile
tags: ${{ steps.meta-pushd.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-pushd.outputs.labels }}
# stoatchat/voice-ingress
- name: Docker meta
id: meta-voice-ingress
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/voice-ingress
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/daemons/voice-ingress/Dockerfile
tags: ${{ steps.meta-voice-ingress.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-voice-ingress.outputs.labels }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta.outputs.labels }}

View File

@@ -1,23 +0,0 @@
name: Documentation (test)
on:
pull_request:
jobs:
test-deploy:
name: Test deployment
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- run: mise docs:build

View File

@@ -1,48 +0,0 @@
name: Documentation
on:
push:
branches:
- main
jobs:
build:
name: Build Docusaurus
runs-on: ubuntu-latest
defaults:
run:
working-directory: ./docs
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- run: mise docs:build
- name: Upload Build Artifact
uses: actions/upload-pages-artifact@v3
with:
path: ./docs/build
deploy:
name: Deploy to GitHub Pages
needs: build
permissions:
pages: write # to deploy to Pages
id-token: write # to verify the deployment originates from an appropriate source
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
runs-on: ubuntu-latest
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

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

View File

@@ -1,26 +0,0 @@
name: Publish Crates
on:
workflow_dispatch:
release:
types: [published]
jobs:
publish:
name: Publish Crates
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: mise publish --workspace

View File

@@ -1,63 +0,0 @@
name: Release Please
on:
push:
branches: [main] # updates/opens the release PR when commits land on main
workflow_dispatch:
permissions:
contents: write
pull-requests: write
id-token: write
concurrency:
group: release-please
cancel-in-progress: true
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.rp.outputs.release_created }}
tag_name: ${{ steps.rp.outputs.tag_name }}
steps:
- id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_STOAT_RELEASE_APP_ID }}
private-key: ${{ secrets.GH_STOAT_RELEASE_APP_PRIVATE_KEY }}
- id: rp
uses: googleapis/release-please-action@v4
with:
token: ${{ steps.app-token.outputs.token }}
config-file: release-please-config.json
- name: Install latest stable
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # v1
with:
toolchain: stable
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
token: ${{ steps.app-token.outputs.token }}
- name: Update Cargo.lock
if: ${{ steps.rp.outputs.prs_created == 'true' || steps.rp.outputs.prs_updated == 'true' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
RP_PR_OUTPUT_JSON: ${{ steps.rp.outputs.prs }}
run: |
PR_NUMBER=$(echo "$RP_PR_OUTPUT_JSON" | jq -r '.[0].number')
gh pr checkout "$PR_NUMBER"
cargo update -w
if git diff --quiet Cargo.lock; then
echo "No changes to Cargo.lock"
else
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add Cargo.lock
git commit -s -m "chore: update Cargo.lock"
git push
fi

View File

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

View File

@@ -1,30 +0,0 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: Renovate
on:
workflow_dispatch:
schedule:
- cron: '0/15 * * * *'
jobs:
renovate:
runs-on: ubuntu-latest
steps:
- id: app-token
uses: actions/create-github-app-token@v2
with:
app-id: ${{ secrets.GH_STOAT_RELEASE_APP_ID }}
private-key: ${{ secrets.GH_STOAT_RELEASE_APP_PRIVATE_KEY }}
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@7b8610a904d57da241e4ddba17fa62b62b15aed4 # use-mise-action-v2.0.2
with:
github_token: ${{ steps.app-token.outputs.token }}
- name: Self-hosted Renovate
uses: renovatebot/github-action@v46.1.14
with:
token: '${{ steps.app-token.outputs.token }}'
env:
RENOVATE_PLATFORM_COMMIT: 'enabled'
RENOVATE_REPOSITORIES: '${{ github.repository }}'

View File

@@ -2,12 +2,9 @@ name: Rust build, test, and generate specification
on:
push:
branches: [main]
branches: [master]
pull_request:
concurrency:
group: ${{ github.head_ref || github.ref }}
cancel-in-progress: true
branches: [master]
env:
CARGO_TERM_COLOR: always
@@ -15,71 +12,65 @@ env:
jobs:
check:
name: Rust project
runs-on: arc-runner-set
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
- uses: actions/checkout@v2
- name: Install latest stable
uses: actions-rs/toolchain@v1
with:
persist-credentials: false
toolchain: stable
override: true
components: rustfmt, clippy
# Using our own runners for now:
# - name: Free up disk space
# run: |
# sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc
- name: Setup Mise
uses: immich-app/devtools/actions/use-mise@cd24790a7f5f6439ac32cc94f5523cb2de8bfa8c # use-mise-action-v1.1.0
- name: Run cargo build
uses: actions-rs/cargo@v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
command: build
- run: mise build
- run: mise docker:start
- name: Reference Test
env:
TEST_DB: REFERENCE
continue-on-error: ${{ github.ref_name == 'main' }}
run: |
mise test
- name: Run cargo test
uses: actions-rs/cargo@v1
with:
command: test
- name: MongoDB Test
env:
TEST_DB: MONGODB
MONGODB: mongodb://localhost
continue-on-error: ${{ github.ref_name == 'main' }}
- name: Copy .env.example
if: github.event_name != 'pull_request'
run: |
mise test
cp .env.example .env
- name: Run services in background
if: github.event_name != 'pull_request'
run: |
docker-compose -f docker-compose.db.yml up -d
- name: Start API in background
if: github.event_name != 'pull_request' && github.ref_name == 'main'
env:
TEST_DB: REFERENCE
if: github.event_name != 'pull_request'
run: |
mise build --bin revolt-delta && (mise service:api &)
cargo run --bin revolt-delta &
- name: Wait for API to go up
if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: nev7n/wait_for_response@7fef3c1a6e8939d0b09062f14fec50d3c5d15fa1 # v1.0.1
if: github.event_name != 'pull_request'
uses: nev7n/wait_for_response@v1
with:
url: "http://localhost:14702/"
url: "http://localhost:8000/"
- name: Checkout API repository
if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
if: github.event_name != 'pull_request'
uses: actions/checkout@v3
with:
repository: stoatchat/javascript-client-api
repository: revoltchat/api
path: api
ssh-key: ${{ secrets.DEPLOY_KEY_JAVASCRIPT_CLIENT_API }}
token: ${{ secrets.PAT }}
- name: Download OpenAPI specification
if: github.event_name != 'pull_request' && github.ref_name == 'main'
run: curl http://localhost:14702/openapi.json -o api/OpenAPI.json
if: github.event_name != 'pull_request'
run: curl http://localhost:8000/openapi.json -o api/OpenAPI.json
- name: Commit changes
if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: EndBug/add-and-commit@a94899bca583c204427a224a7af87c02f9b325d5 # v9.1.4
if: github.event_name != 'pull_request'
uses: EndBug/add-and-commit@v4
with:
cwd: "api"
add: "*.json"
author_name: Stoat CI
author_email: stoat-ci@users.noreply.github.com
author_name: Revolt CI
author_email: revolt-ci@users.noreply.github.com
message: "chore: generate OpenAPI specification"

54
.github/workflows/triage_issue.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: Add Issue to Board
on:
issues:
types: [opened]
jobs:
track_issue:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectV2(number: 3) {
id
fields(first:20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'TODO_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Todo") |.id' project_data.json) >> $GITHUB_ENV
- name: Add issue to project
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
ISSUE_ID: ${{ github.event.issue.node_id }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $issue:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $issue}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f issue=$ISSUE_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV

79
.github/workflows/triage_pr.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
name: Add PR to Board
on:
pull_request_target:
types: [opened, synchronize, ready_for_review, review_requested]
jobs:
track_pr:
runs-on: ubuntu-latest
steps:
- name: Get project data
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
query {
organization(login: "revoltchat"){
projectV2(number: 3) {
id
fields(first:20) {
nodes {
... on ProjectV2SingleSelectField {
id
name
options {
id
name
}
}
}
}
}
}
}' > project_data.json
echo 'PROJECT_ID='$(jq '.data.organization.projectV2.id' project_data.json) >> $GITHUB_ENV
echo 'STATUS_FIELD_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .id' project_data.json) >> $GITHUB_ENV
echo 'INCOMING_OPTION_ID='$(jq '.data.organization.projectV2.fields.nodes[] | select(.name== "Status") | .options[] | select(.name=="Incoming PRs") |.id' project_data.json) >> $GITHUB_ENV
- name: Add PR to project
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
PR_ID: ${{ github.event.pull_request.node_id }}
run: |
item_id="$( gh api graphql -f query='
mutation($project:ID!, $pr:ID!) {
addProjectV2ItemById(input: {projectId: $project, contentId: $pr}) {
item {
id
}
}
}' -f project=$PROJECT_ID -f pr=$PR_ID --jq '.data.addProjectV2ItemById.item.id')"
echo 'ITEM_ID='$item_id >> $GITHUB_ENV
- name: Set fields
env:
GITHUB_TOKEN: ${{ secrets.PAT }}
run: |
gh api graphql -f query='
mutation (
$project: ID!
$item: ID!
$status_field: ID!
$status_value: String!
) {
set_status: updateProjectV2ItemFieldValue(input: {
projectId: $project
itemId: $item
fieldId: $status_field
value: {
singleSelectOptionId: $status_value
}
}) {
projectV2Item {
id
}
}
}' -f project=$PROJECT_ID -f item=$ITEM_ID -f status_field=$STATUS_FIELD_ID -f status_value=${{ env.INCOMING_OPTION_ID }} --silent

View File

@@ -1,23 +0,0 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: "Lint PR"
on:
pull_request_target:
types:
- opened
- reopened
- edited
- synchronize
jobs:
main:
name: Validate PR title
runs-on: ubuntu-latest
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

9
.gitignore vendored
View File

@@ -1,15 +1,6 @@
Rocket.toml
Revolt.*.toml
compose.override.yml
target
.data
.env
.venv/
venv/
.vercel
.DS_Store
livekit.yml
.idea

View File

@@ -1,28 +0,0 @@
[tools]
node = "25.4.0"
pnpm = "10.28.1"
gh = "2.25.0"
rust = "1.92.0"
"cargo:cargo-nextest" = "0.9.122"
"github:git-town/git-town" = "22.4.0"
[settings]
experimental = true
idiomatic_version_file_enable_tools = ["rust"]
[tasks.start]
description = "Run all services"
depends = ["docker:start", "build"]
wait_for = ["docker:start", "build"]
run = [{ task = "service:*" }]
[env]
BUILDER = "cargo"
DOCKER_NETWORK_NAME = "stoatchat_default"
DATABASE_PORT = "27017"
RABBIT_PORT = "5672"
REDIS_PORT = "6379"
_.file = { path = ".env", tools = true }

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Build project"
set -e
${BUILDER} build "$@"

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Check project with clippy"
set -e
cargo clippy

View File

@@ -1,13 +0,0 @@
#!/usr/bin/env bash
#MISE description="Start Docker containers"
set -e
docker compose up -d
docker run \
--network=${DOCKER_NETWORK_NAME} \
--name wait \
--rm dokku/wait -c \
rabbit:${RABBIT_PORT},\
database:${DATABASE_PORT},\
redis:${REDIS_PORT}

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Stop Docker containers"
set -e
docker compose down

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env bash
#MISE description="Start the Stoat Developers website"
#MISE depends=["docs:install"]
#MISE dir="{{config_root}}/docs"
set -e
pnpm build

View File

@@ -1,7 +0,0 @@
#!/usr/bin/env bash
#MISE description="Build the Stoat Developers website"
#MISE depends=["docs:install"]
#MISE dir="{{config_root}}/docs"
set -e
pnpm build

View File

@@ -1,6 +0,0 @@
#!/usr/bin/env bash
#MISE description="Install dependencies for docs site"
#MISE dir="{{config_root}}/docs"
set -e
pnpm i --frozen-lockfile

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Publish project"
set -e
cargo publish "$@"

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run API server"
set -e
cargo run --bin revolt-delta

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run cron daemon"
set -e
cargo run --bin revolt-crond

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run events server"
set -e
cargo run --bin revolt-bonfire

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run file server"
set -e
cargo run --bin revolt-autumn

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run GIF proxy server"
set -e
cargo run --bin revolt-gifbox

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run proxy server"
set -e
cargo run --bin revolt-january

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
#MISE description="Run push daemon"
set -e
cargo run --bin revolt-pushd

View File

@@ -1,8 +0,0 @@
#!/usr/bin/env bash
#MISE description="Test project"
set -e
: "${TEST_DB:=REFERENCE}"
export TEST_DB
cargo nextest run

View File

@@ -1,3 +0,0 @@
{
".": "0.13.7"
}

View File

@@ -1,6 +1,4 @@
{
"editor.formatOnSave": true,
"rust-analyzer.check.command": "clippy",
"nixEnvSelector.suggestion": false,
"nixEnvSelector.nixFile": "${workspaceFolder}/default.nix"
}
"editor.formatOnSave": true,
"rust-analyzer.checkOnSave.command": "clippy"
}

View File

@@ -1,288 +0,0 @@
# 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)
### Features
* add bug report template for issue tracking ([#627](https://github.com/stoatchat/stoatchat/issues/627)) ([f777e28](https://github.com/stoatchat/stoatchat/commit/f777e2863c6ca50057c8b5d0a5be14915d287724))
* Add slowmode functionality to text channels ([#680](https://github.com/stoatchat/stoatchat/issues/680)) ([6107f24](https://github.com/stoatchat/stoatchat/commit/6107f242fd3ebaff71a15f9a16330ffbcb4f2d7b))
* Allow restricting server creation to specific users ([#685](https://github.com/stoatchat/stoatchat/issues/685)) ([edfa97d](https://github.com/stoatchat/stoatchat/commit/edfa97db108c9c81828547f98a1db5315cb5ba4a))
* compute thumbhash for images ([#596](https://github.com/stoatchat/stoatchat/issues/596)) ([c2d4369](https://github.com/stoatchat/stoatchat/commit/c2d4369e160f32d79bce0a0b0f14677f89de3669))
* Detect animation in image files for fetch_preview ([#574](https://github.com/stoatchat/stoatchat/issues/574)) ([3fa0abf](https://github.com/stoatchat/stoatchat/commit/3fa0abf47f5f42ddd8ee041fe4c44fbc5ba800c1))
* expose global and user limits in root API response ([#644](https://github.com/stoatchat/stoatchat/issues/644)) ([0b522eb](https://github.com/stoatchat/stoatchat/commit/0b522ebddc17f2e3f792ff5e2347793e9849fa23))
* implement time based message sweep on user ban ([#670](https://github.com/stoatchat/stoatchat/issues/670)) ([98c7b1b](https://github.com/stoatchat/stoatchat/commit/98c7b1b5a5b9fdac5c0ab83be10f0e23114dbfc9))
* load config from env vars ([#576](https://github.com/stoatchat/stoatchat/issues/576)) ([5191bd1](https://github.com/stoatchat/stoatchat/commit/5191bd16b2a905b8409838e34eb0baca96f08580))
* parse message push notification content and replace internal formatting ([#693](https://github.com/stoatchat/stoatchat/issues/693)) ([d1e72ce](https://github.com/stoatchat/stoatchat/commit/d1e72cee42c54e16f4e49af569897528b10a28ca))
* Transfer ownership ([#396](https://github.com/stoatchat/stoatchat/issues/396)) ([735d644](https://github.com/stoatchat/stoatchat/commit/735d644e043793cb86e74aab5b88bb4b8bc17ba2))
* update livekit ([#698](https://github.com/stoatchat/stoatchat/issues/698)) ([f181edc](https://github.com/stoatchat/stoatchat/commit/f181edc8f2ff3ce4b6d48938dfc73931ecfa2279))
### Bug Fixes
* add flag for disabling events instead of commenting them out ([#695](https://github.com/stoatchat/stoatchat/issues/695)) ([a5cd08a](https://github.com/stoatchat/stoatchat/commit/a5cd08a655dece4269f3ac84fa2387ae356709a5))
* add masquerade permission to default direct message settings ([#665](https://github.com/stoatchat/stoatchat/issues/665)) ([ab52569](https://github.com/stoatchat/stoatchat/commit/ab525699bd6663333f0e9fed6d2455e482e6a09f))
* Check for appropriate permission for removing other users avatar ([#657](https://github.com/stoatchat/stoatchat/issues/657)) ([d56135e](https://github.com/stoatchat/stoatchat/commit/d56135e0cbc713884c9378832952f7ad490fa315))
* default video resolution is a non-existent size ([#601](https://github.com/stoatchat/stoatchat/issues/601)) ([0698e11](https://github.com/stoatchat/stoatchat/commit/0698e115e8d003d615e468c4fb9654e6bbc9107f)), closes [#588](https://github.com/stoatchat/stoatchat/issues/588)
* **docs:** Update GitHub links ([#647](https://github.com/stoatchat/stoatchat/issues/647)) ([b830631](https://github.com/stoatchat/stoatchat/commit/b830631bd25a546844b7bdd30386084bb365e4de))
* don't use a bitop for OR ([#676](https://github.com/stoatchat/stoatchat/issues/676)) ([5701b5c](https://github.com/stoatchat/stoatchat/commit/5701b5c18c513f796af365169ceaea372a22638c))
* Fix typo for p256dh in vapid notification flow ([#622](https://github.com/stoatchat/stoatchat/issues/622)) ([a80ad1c](https://github.com/stoatchat/stoatchat/commit/a80ad1cbe58b8af5e45751e51d94d93c1cea1c9f))
* improve generated openapi.json ([#584](https://github.com/stoatchat/stoatchat/issues/584)) ([52ed510](https://github.com/stoatchat/stoatchat/commit/52ed5100c2446e0b261085639e123e7e124cab2c))
* no node state set on channel creation ([#653](https://github.com/stoatchat/stoatchat/issues/653)) ([24d0d2b](https://github.com/stoatchat/stoatchat/commit/24d0d2b7266f6f8a692d0a52704acfecf517674c))
* only show first line on commit messages ([#696](https://github.com/stoatchat/stoatchat/issues/696)) ([91783b9](https://github.com/stoatchat/stoatchat/commit/91783b906697fc85305dee683f7c15dda55f0c50))
* pass &str to Reference ([#697](https://github.com/stoatchat/stoatchat/issues/697)) ([ccda6f5](https://github.com/stoatchat/stoatchat/commit/ccda6f5c53ee043705f7ff6b5f6c393f020781de))
* redis_url vs redis_uri in config ([#666](https://github.com/stoatchat/stoatchat/issues/666)) ([b0b728f](https://github.com/stoatchat/stoatchat/commit/b0b728fb0dbc9ee28360301de1c3ea501bbbff1d))
* replace some links and Revolt mentions to current Stoat ([#515](https://github.com/stoatchat/stoatchat/issues/515)) ([d629e89](https://github.com/stoatchat/stoatchat/commit/d629e89304be2f0011e189293b278f07d346aa7d))
* send push notifications for DM and group messages ([#660](https://github.com/stoatchat/stoatchat/issues/660)) ([52c0d2f](https://github.com/stoatchat/stoatchat/commit/52c0d2f266b76d8975bba2d5e75c62bb30149c45))
* store server id in redis and in room metadata to be able to delete voice state in all scenarios ([#656](https://github.com/stoatchat/stoatchat/issues/656)) ([49c6289](https://github.com/stoatchat/stoatchat/commit/49c628958070e4f0a5edc764d3b48158589219d9))
* uname is missing from crond ([#675](https://github.com/stoatchat/stoatchat/issues/675)) ([dc4438b](https://github.com/stoatchat/stoatchat/commit/dc4438bc3c7b2cad8d442b3cd438afb9ed566a5e))
## [0.11.5](https://github.com/stoatchat/stoatchat/compare/v0.11.4...v0.11.5) (2026-02-17)
### Reverts
* disable user update events ([#593](https://github.com/stoatchat/stoatchat/issues/593)) ([1c98ead](https://github.com/stoatchat/stoatchat/commit/1c98ead69579b4700be0b51c9020bb8402336cc6))
## [0.11.4](https://github.com/stoatchat/stoatchat/compare/v0.11.3...v0.11.4) (2026-02-16)
### Bug Fixes
* add separate config option for redis events replica url ([#590](https://github.com/stoatchat/stoatchat/issues/590)) ([a75e4ea](https://github.com/stoatchat/stoatchat/commit/a75e4eabfc4b34aba7620c82ba77558a32d9e10a))
## [0.11.3](https://github.com/stoatchat/stoatchat/compare/v0.11.2...v0.11.3) (2026-02-13)
### Bug Fixes
* cut presence traffic too while we engineer a new events architecture ([#561](https://github.com/stoatchat/stoatchat/issues/561)) ([1f8ea96](https://github.com/stoatchat/stoatchat/commit/1f8ea963ad742f693f405e6438f1c343c81e6579))
## [0.11.2](https://github.com/stoatchat/stoatchat/compare/v0.11.1...v0.11.2) (2026-02-13)
### Bug Fixes
* cut events traffic while we engineer a new events architecture ([#559](https://github.com/stoatchat/stoatchat/issues/559)) ([a11986b](https://github.com/stoatchat/stoatchat/commit/a11986ba1ad16b672ff1080913a684567d88adbb))
## [0.11.1](https://github.com/stoatchat/stoatchat/compare/v0.11.0...v0.11.1) (2026-02-13)
### Bug Fixes
* bots in multiple voice channel logic ([#544](https://github.com/stoatchat/stoatchat/issues/544)) ([94cb916](https://github.com/stoatchat/stoatchat/commit/94cb916231b9b8befb2e94065917ff40815bec52))
## [0.11.0](https://github.com/stoatchat/stoatchat/compare/v0.10.3...v0.11.0) (2026-02-10)
### Features
* appeal to the almighty Spamhaus ([#524](https://github.com/stoatchat/stoatchat/issues/524)) ([5132270](https://github.com/stoatchat/stoatchat/commit/5132270f2edd6df25ce414daa42ed1b2aa6fa7a9))
## [0.10.3](https://github.com/stoatchat/stoatchat/compare/v0.10.2...v0.10.3) (2026-02-07)
### Bug Fixes
* update `Revolt` -&gt; `Stoat` in email titles/desc. ([#508](https://github.com/stoatchat/stoatchat/issues/508)) ([84483ce](https://github.com/stoatchat/stoatchat/commit/84483cee7af3e5dfa16f7fe13e334c4d9f5abd60))
## [0.10.2](https://github.com/stoatchat/stoatchat/compare/v0.10.1...v0.10.2) (2026-01-25)
### Bug Fixes
* thumbnailification requires rgb8/rgba8 ([#505](https://github.com/stoatchat/stoatchat/issues/505)) ([413aa04](https://github.com/stoatchat/stoatchat/commit/413aa04dcaf8bff3935ed1e5f31432e11a03ce6f))
## [0.10.1](https://github.com/stoatchat/stoatchat/compare/v0.10.0...v0.10.1) (2026-01-25)
### Bug Fixes
* use Rust 1.92.0 for Docker build ([#503](https://github.com/stoatchat/stoatchat/issues/503)) ([98da8a2](https://github.com/stoatchat/stoatchat/commit/98da8a28a0aa2fee4e8eee1d86bd7c49e3187477))
## [0.10.0](https://github.com/stoatchat/stoatchat/compare/v0.9.4...v0.10.0) (2026-01-25)
### Features
* allow kicking members from voice channels ([#495](https://github.com/stoatchat/stoatchat/issues/495)) ([0dc5442](https://github.com/stoatchat/stoatchat/commit/0dc544249825a49c793309edee5ec1838458a6da))
* repository architecture for files crate w. added tests ([#498](https://github.com/stoatchat/stoatchat/issues/498)) ([01ded20](https://github.com/stoatchat/stoatchat/commit/01ded209c62208fc906d6aab9b08c04e860e10ef))
### Bug Fixes
* expose ratelimit headers via cors ([#496](https://github.com/stoatchat/stoatchat/issues/496)) ([a1a2125](https://github.com/stoatchat/stoatchat/commit/a1a21252d0ad58937e41f16e5fb86f96bebd2a51))
## [0.9.4](https://github.com/stoatchat/stoatchat/compare/v0.9.3...v0.9.4) (2026-01-10)
### Bug Fixes
* checkout repo. before bumping lock ([#490](https://github.com/stoatchat/stoatchat/issues/490)) ([b2da2a8](https://github.com/stoatchat/stoatchat/commit/b2da2a858787853be43136fd526a0bd72baf78ef))
* persist credentials for git repo ([#492](https://github.com/stoatchat/stoatchat/issues/492)) ([c674a9f](https://github.com/stoatchat/stoatchat/commit/c674a9fd4e0abbd51569870e4b38074d4a1de03c))
## [0.9.3](https://github.com/stoatchat/stoatchat/compare/v0.9.2...v0.9.3) (2026-01-10)
### Bug Fixes
* pipeline fixes ([#487](https://github.com/stoatchat/stoatchat/issues/487)) ([aeeafeb](https://github.com/stoatchat/stoatchat/commit/aeeafebefc36a43a656cf797c9251ca50292733c))
## [0.9.2](https://github.com/stoatchat/stoatchat/compare/v0.9.1...v0.9.2) (2026-01-10)
### Bug Fixes
* disable publish for services ([#485](https://github.com/stoatchat/stoatchat/issues/485)) ([d13609c](https://github.com/stoatchat/stoatchat/commit/d13609c37279d6a40445dcd99564e5c3dd03bac1))
## [0.9.1](https://github.com/stoatchat/stoatchat/compare/v0.9.0...v0.9.1) (2026-01-10)
### Bug Fixes
* **ci:** pipeline fixes (marked as fix to force release) ([#483](https://github.com/stoatchat/stoatchat/issues/483)) ([303e52b](https://github.com/stoatchat/stoatchat/commit/303e52b476585eea81c33837f1b01506ce387684))
## [0.9.0](https://github.com/stoatchat/stoatchat/compare/v0.8.8...v0.9.0) (2026-01-10)
### Features
* add id field to role ([#470](https://github.com/stoatchat/stoatchat/issues/470)) ([2afea56](https://github.com/stoatchat/stoatchat/commit/2afea56e56017f02de98e67316b4457568ad5b26))
* add ratelimits to gifbox ([1542047](https://github.com/stoatchat/stoatchat/commit/154204742d21cbeff6e2577b00f50b495ea44631))
* include groups and dms in fetch mutuals ([caa8607](https://github.com/stoatchat/stoatchat/commit/caa86074680d46223cebc20f41e9c91c41ec825d))
* include member payload in ServerMemberJoin event ([480f210](https://github.com/stoatchat/stoatchat/commit/480f210ce85271e13d1dac58a5dae08de108579d))
* initial work on tenor gif searching ([b0c977b](https://github.com/stoatchat/stoatchat/commit/b0c977b324b8144c1152589546eb8fec5954c3e7))
* make message lexer use unowned string ([1561481](https://github.com/stoatchat/stoatchat/commit/1561481eb4cdc0f385fbf0a81e4950408050e11f))
* ready payload field customisation ([db57706](https://github.com/stoatchat/stoatchat/commit/db577067948f13e830b5fb773034e9713a1abaff))
* require auth for search ([b5cd5e3](https://github.com/stoatchat/stoatchat/commit/b5cd5e30ef7d5e56e8964fb7c543965fa6bf5a4a))
* trending and categories routes ([5885e06](https://github.com/stoatchat/stoatchat/commit/5885e067a627b8fff1c8ce2bf9e852ff8cf3f07a))
* voice chats v2 ([#414](https://github.com/stoatchat/stoatchat/issues/414)) ([d567155](https://github.com/stoatchat/stoatchat/commit/d567155f124e4da74115b1a8f810062f7c6559d9))
### Bug Fixes
* add license to revolt-parser ([5335124](https://github.com/stoatchat/stoatchat/commit/53351243064cac8d499dd74284be73928fa78a43))
* allow for disabling default features ([65fbd36](https://github.com/stoatchat/stoatchat/commit/65fbd3662462aed1333b79e59155fa6377e83fcc))
* apple music to use original url instead of metadata url ([bfe4018](https://github.com/stoatchat/stoatchat/commit/bfe4018e436a4075bae780dd4d35a9b58315e12f))
* apply uname fix to january and autumn ([8f9015a](https://github.com/stoatchat/stoatchat/commit/8f9015a6ff181d208d9269ab8691bd417d39811a))
* **ci:** publish images under stoatchat and remove docker hub ([d65c1a1](https://github.com/stoatchat/stoatchat/commit/d65c1a1ab3bdc7e5684b03f280af77d881661a3d))
* correct miniz_oxide in lockfile ([#478](https://github.com/stoatchat/stoatchat/issues/478)) ([5d27a91](https://github.com/stoatchat/stoatchat/commit/5d27a91e901dd2ea3e860aeaed8468db6c5f3214))
* correct shebang for try-tag-and-release ([050ba16](https://github.com/stoatchat/stoatchat/commit/050ba16d4adad5d0fb247867aa3e94e3d42bd12d))
* correct string_cache in lockfile ([#479](https://github.com/stoatchat/stoatchat/issues/479)) ([0b178fc](https://github.com/stoatchat/stoatchat/commit/0b178fc791583064bf9ca94b1d39b42d021e1d79))
* don't remove timeouts when a member leaves a server ([#409](https://github.com/stoatchat/stoatchat/issues/409)) ([e635bc2](https://github.com/stoatchat/stoatchat/commit/e635bc23ec857d648d5705e1a3875d7bc3402b0d))
* don't update the same field while trying to remove it ([f4ee35f](https://github.com/stoatchat/stoatchat/commit/f4ee35fb093ca49f0a64ff4b17fd61587df28145)), closes [#392](https://github.com/stoatchat/stoatchat/issues/392)
* github webhook incorrect payload and formatting ([#468](https://github.com/stoatchat/stoatchat/issues/468)) ([dc9c82a](https://github.com/stoatchat/stoatchat/commit/dc9c82aa4e9667ea6639256c65ac8de37a24d1f7))
* implement Serialize to ClientMessage ([dea0f67](https://github.com/stoatchat/stoatchat/commit/dea0f675dde7a63c7a59b38d469f878b7a8a3af4))
* newly created roles should be ranked the lowest ([947eb15](https://github.com/stoatchat/stoatchat/commit/947eb15771ed6785b3dcd16c354c03ded5e4cbe0))
* permit empty `remove` array in edit requests ([6ad3da5](https://github.com/stoatchat/stoatchat/commit/6ad3da5f35f989a2e7d8e29718b98374248e76af))
* preserve order of replies in message ([#447](https://github.com/stoatchat/stoatchat/issues/447)) ([657a3f0](https://github.com/stoatchat/stoatchat/commit/657a3f08e5d652814bbf0647e089ed9ebb139bbf))
* prevent timing out members which have TimeoutMembers permission ([e36fc97](https://github.com/stoatchat/stoatchat/commit/e36fc9738bac0de4f3fcbccba521f1e3754f7ae7))
* relax settings name regex ([3a34159](https://github.com/stoatchat/stoatchat/commit/3a3415915f0d0fdce1499d47a2b7fa097f5946ea))
* remove authentication tag bytes from attachment download ([32e6600](https://github.com/stoatchat/stoatchat/commit/32e6600272b885c595c094f0bc69459250220dcb))
* rename openapi operation ids ([6048587](https://github.com/stoatchat/stoatchat/commit/6048587d348fbca0dc3a9b47690c56df8fece576)), closes [#406](https://github.com/stoatchat/stoatchat/issues/406)
* respond with 201 if no body in requests ([#465](https://github.com/stoatchat/stoatchat/issues/465)) ([24fedf8](https://github.com/stoatchat/stoatchat/commit/24fedf8c4d9cd3160bdec97aa451520f8beaa739))
* swap to using reqwest for query building ([38dd4d1](https://github.com/stoatchat/stoatchat/commit/38dd4d10797b3e6e397fc219e818f379bdff19f2))
* use `trust_cloudflare` config value instead of env var ([cc7a796](https://github.com/stoatchat/stoatchat/commit/cc7a7962a882e1627fcd0bc75858a017415e8cfc))
* use our own result types instead of tenors types ([a92152d](https://github.com/stoatchat/stoatchat/commit/a92152d86da136997817e797c7af8e38731cdde8))

10371
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,205 +1,7 @@
[workspace]
resolver = "3"
members = [
"crates/delta",
"crates/bonfire",
"crates/core/*",
"crates/services/*",
"crates/daemons/*",
]
members = ["crates/*", "crates/bridges/*"]
exclude = ["crates/bridges"]
[patch.crates-io]
redis23 = { package = "redis", version = "0.23.3", git = "https://github.com/revoltchat/redis-rs", rev = "523b2937367e17bd0073722bf6e23d06042cb4e4" }
#authifier = { package = "authifier", version = "1.0.10", path = "../authifier/crates/authifier" }
#rocket_authifier = { package = "rocket_authifier", version = "1.0.10", path = "../authifier/crates/rocket_authifier" }
[profile.release]
lto = true
[workspace.dependencies]
# Async
async-trait = "0.1.89"
tokio = "1.49.0"
async-channel = "2.3.1"
futures = "0.3.32"
async-std = "1.8.0"
async-tungstenite = "0.17.0"
futures-locks = "0.7.1"
async-lock = "2.8.0"
async-recursion = "1.0.4"
# Error Handling
anyhow = "1.0.100"
thiserror = "2.0.18"
sentry = "0.31.5"
sentry-anyhow = "0.38.1"
# Data Validation
regex = "1.12.3"
validator = "0.16"
# Data Types
uuid = "1.19.0"
ulid = "1.2.1"
nanoid = "0.4.0"
typenum = "1.17.0"
num_enum = "0.6.1"
bitfield = "0.13.2"
# Time
chrono = "0.4.15"
iso8601-timestamp = "0.2.10"
# Data Collections
lru = "0.16.3"
indexmap = "2.13.1"
dashmap = "5.2.0"
moka = "0.12.8"
lru_time_cache = "0.11.11"
deadqueue = "0.2.4"
# Web scraping
scraper = "0.20.0"
encoding_rs = "0.8.34"
# Mail
lettre = "0.10.0-alpha.4"
handlebars = "4.3.0"
# HTTP Requests
reqwest = "0.13.2"
isahc = "1.7"
# Notifications
fcm_v1 = "0.3.0"
web-push = "0.10.0"
revolt_a2 = "0.10"
# Parsing
logos = "0.15"
# SVG rendering
usvg = "0.44.0"
resvg = "0.44.0"
tiny-skia = "0.11.4"
# Logging
log = "0.4.29"
pretty_env_logger = "0.4.0"
# Redis
redis-kiss = "0.1.4"
fred = "8.0.1"
# Serialisation
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = { version = "1", features = ["derive"] }
strum_macros = "0.26.4"
# MongoDB
bson = { version = "2.1.0" }
mongodb = { version = "3.1.0" }
# S3
aws-config = "1.5.5"
aws-sdk-s3 = "1.46.0"
# Axum (HTTP server)
axum-macros = "0.4.1"
axum_typed_multipart = "0.12.1"
axum = "0.7.5"
axum-extra = "0.9"
tower-http = "0.5.2"
# Rocket (HTTP server)
rocket = "0.5.1"
rocket_empty = "0.1.1"
revolt_rocket_okapi = "0.10.0"
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_authifier = "1.0.16"
rocket_prometheus = "0.10.0-rc.3"
# Spec Generation
utoipa = "4.2.3"
revolt_okapi = "0.9.1"
schemars = "0.8.8"
utoipa-scalar = "0.1.0"
# Image Processing
jxl-oxide = "0.12.5"
sha2 = "0.10.8"
kamadak-exif = "0.5.4"
webp = "0.3.0"
image = "0.25.2" # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = "0.1.0"
lcms2 = "6.1.1" # for color profile processing
# File processing
revolt_clamav-client = "0.1.5"
simdutf8 = "0.1.4"
# Content type processing
infer = "0.16.0"
ffprobe = "0.4.0"
imagesize = "0.13.0"
# OpenTelemetry
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = [
"env-filter",
] } # consider https://crates.io/crates/better-tracing
opentelemetry = { version = "0.31.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.31.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.31.0", features = ["logs"] }
opentelemetry-appender-tracing = "0.31.1"
# 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"
totp-lite = "2.0.0"
rust-argon2 = "1.0.0"
base32 = "0.4.0"
sha1 = "0.10.6"
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" }
# mobc-redis = { git = "https://github.com/insertish/mobc", rev = "8b880bb59f2ba80b4c7bc40c649c113d8857a186" }
redis = { git = "https://github.com/insertish/redis-rs", rev = "6575a6b1c09eb8c9cc7f0082d95fe6b8f903c4d7" }

View File

@@ -1,5 +1,5 @@
# Build Stage
FROM --platform="${BUILDPLATFORM}" rust:1.92.0-slim-bookworm
FROM --platform="${BUILDPLATFORM}" rustlang/rust:nightly-slim
USER 0:0
WORKDIR /home/rust/src
@@ -19,22 +19,7 @@ RUN sh /tmp/build-image-layer.sh tools
COPY Cargo.toml Cargo.lock ./
COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/files/Cargo.toml ./crates/core/files/
COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/parser/Cargo.toml ./crates/core/parser/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/core/coalesced/Cargo.toml ./crates/core/coalesced/
COPY crates/core/ratelimits/Cargo.toml ./crates/core/ratelimits/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
COPY crates/daemons/voice-ingress/Cargo.toml ./crates/daemons/voice-ingress/
COPY crates/quark/Cargo.toml ./crates/quark/
RUN sh /tmp/build-image-layer.sh deps
# Build all apps

View File

@@ -1,38 +0,0 @@
# Build Stage
FROM rust:1.92.0-slim-bookworm
USER 0:0
WORKDIR /home/rust/src
# Install build requirements
RUN apt-get update && \
apt-get install -y \
make \
pkg-config \
libssl-dev
COPY scripts/build-image-layer.sh /tmp/
# Build all dependencies
COPY Cargo.toml Cargo.lock ./
COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/files/Cargo.toml ./crates/core/files/
COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/parser/Cargo.toml ./crates/core/parser/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/core/coalesced/Cargo.toml ./crates/core/coalesced/
COPY crates/core/ratelimits/Cargo.toml ./crates/core/ratelimits/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
COPY crates/daemons/voice-ingress/Cargo.toml ./crates/daemons/voice-ingress/
RUN sh /tmp/build-image-layer.sh deps
# Build all apps
COPY crates ./crates
RUN sh /tmp/build-image-layer.sh apps

View File

@@ -1,7 +1,4 @@
With the exception of crates that specify their own LICENSE file,
the following license applies to the source code of this project.
GNU AFFERO GENERAL PUBLIC LICENSE
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>

228
README.md
View File

@@ -1,219 +1,31 @@
<div align="center">
<h1>
Stoat Backend
[![Stars](https://img.shields.io/github/stars/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/stargazers)
[![Forks](https://img.shields.io/github/forks/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/pulls)
[![Issues](https://img.shields.io/github/issues/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/issues)
[![Contributors](https://img.shields.io/github/contributors/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/graphs/contributors)
[![License](https://img.shields.io/github/license/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/blob/main/LICENSE)
</h1>
The services and libraries that power the Stoat service.<br/>
<br/>
# Revolt Backend
| Crate | Path | Description | |
| ------------------ | -------------------------------------------------- | ----------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core/config` | [crates/core/config](crates/core/config) | Core: Configuration | ![Crates.io Version](https://img.shields.io/crates/v/revolt-config) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-config) ![Crates.io Version](https://img.shields.io/crates/size/revolt-config) ![Crates.io License](https://img.shields.io/crates/l/revolt-config) |
| `core/database` | [crates/core/database](crates/core/database) | Core: Database Implementation | ![Crates.io Version](https://img.shields.io/crates/v/revolt-database) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-database) ![Crates.io Version](https://img.shields.io/crates/size/revolt-database) ![Crates.io License](https://img.shields.io/crates/l/revolt-database) |
| `core/files` | [crates/core/files](crates/core/files) | Core: S3 and encryption subroutines | ![Crates.io Version](https://img.shields.io/crates/v/revolt-files) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-files) ![Crates.io Version](https://img.shields.io/crates/size/revolt-files) ![Crates.io License](https://img.shields.io/crates/l/revolt-files) |
| `core/models` | [crates/core/models](crates/core/models) | Core: API Models | ![Crates.io Version](https://img.shields.io/crates/v/revolt-models) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-models) ![Crates.io Version](https://img.shields.io/crates/size/revolt-models) ![Crates.io License](https://img.shields.io/crates/l/revolt-models) |
| `core/permissions` | [crates/core/permissions](crates/core/permissions) | Core: Permission Logic | ![Crates.io Version](https://img.shields.io/crates/v/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/size/revolt-permissions) ![Crates.io License](https://img.shields.io/crates/l/revolt-permissions) |
| `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence | ![Crates.io Version](https://img.shields.io/crates/v/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/size/revolt-presence) ![Crates.io License](https://img.shields.io/crates/l/revolt-presence) |
| `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types | ![Crates.io Version](https://img.shields.io/crates/v/revolt-result) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-result) ![Crates.io Version](https://img.shields.io/crates/size/revolt-result) ![Crates.io License](https://img.shields.io/crates/l/revolt-result) |
| `core/coalesced` | [crates/core/coalesced](crates/core/coalesced) | Core: Coalescion service | ![Crates.io Version](https://img.shields.io/crates/v/revolt-coalesced) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-coalesced) ![Crates.io Version](https://img.shields.io/crates/size/revolt-coalesced) ![Crates.io License](https://img.shields.io/crates/l/revolt-coalesced) |
| `delta` | [crates/delta](crates/delta) | REST API server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/january` | [crates/services/january](crates/services/january) | Proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/gifbox` | [crates/services/gifbox](crates/services/gifbox) | Tenor proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/autumn` | [crates/services/autumn](crates/services/autumn) | File server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `daemons/crond` | [crates/daemons/crond](crates/daemons/crond) | Timed data clean up daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `daemons/pushd` | [crates/daemons/pushd](crates/daemons/pushd) | Push notification daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
This is a monorepo for the Revolt backend.
</div>
<br/>
| Crate | Path | Description |
| ---------------- | ---------------------------------------------- | ------------------------------------ |
| `delta` | [crates/delta](crates/delta) | REST API server |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server |
| `quark` | [crates/quark](crates/quark) | Models and logic |
<!--| `revcord/api` | [crates/revcord/api](crates/revcord/api) | Discord REST translation layer |
| `revcord/ws` | [crates/revcord/ws](crates/revcord/ws) | Discord gateway translation layer |
| `revcord/models` | [crates/revcord/models](crates/revcord/models) | Discord models and quark translation |-->
## Minimum Supported Rust Version
Note: `january`, `autumn`, and `vortex` are yet to be moved into this monorepo.
Rust 1.86.0 or higher.
## Resources
## Development Guide
### Revolt
Before contributing, make yourself familiar with [our contribution guidelines](https://developers.stoat.chat/developing/contrib/) and the [technical documentation for this project](https://developers.stoat.chat/).
- [Revolt Project Board](https://github.com/revoltchat/revolt/discussions) (Submit feature requests here)
- [Revolt Testers Server](https://app.revolt.chat/invite/Testers)
- [Contribution Guide](https://developers.revolt.chat/contributing)
Before getting started, you'll want to install:
## Contributing
- mise
- Docker
- Git
- mold (optional, faster compilation)
> A **default.nix** is available for Nix users!
> Run `nix-shell` to activate mise.
As a heads-up, the development environment uses the following ports:
| Service | Port |
| ------------------------- | :------------: |
| MongoDB | 27017 |
| Redis | 6379 |
| MinIO | 14009 |
| Maildev | 14025<br>14080 |
| Revolt Web App | 14701 |
| RabbitMQ | 5672<br>15672 |
| `crates/delta` | 14702 |
| `crates/bonfire` | 14703 |
| `crates/services/autumn` | 14704 |
| `crates/services/january` | 14705 |
| `crates/services/gifbox` | 14706 |
Now you can clone and build the project:
```bash
git clone https://github.com/stoatchat/stoatchat stoat-backend
cd stoat-backend
mise install
mise build
```
> [!TIP]
> You can override `BUILDER` in your `.env` file to run cargo with mold if you installed it:
>
> ```bash
> # .env
> BUILDER = "mold --run cargo"
> ```
A default configuration `Revolt.toml` is present in this project that is suited for development.
If you'd like to change anything, create a `Revolt.overrides.toml` file and specify relevant variables.
> [!TIP]
> Use Sentry to catch unexpected service errors:
>
> ```toml
> # Revolt.overrides.toml
> [sentry]
> api = "https://abc@your.sentry/1"
> events = "https://abc@your.sentry/1"
> files = "https://abc@your.sentry/1"
> proxy = "https://abc@your.sentry/1"
> ```
> [!TIP]
> If you have port conflicts on common services, you can try the following:
>
> ```yaml
> # compose.override.yml
> services:
> redis:
> ports: !override
> - "14079:6379"
>
> database:
> ports: !override
> - "14017:27017"
>
> rabbit:
> ports: !override
> - "14072:5672"
> - "14672:15672"
> ```
>
> With the corresponding Revolt configuration:
>
> ```toml
> # Revolt.overrides.toml
> # and Revolt.test-overrides.toml
> [database]
> mongodb = "mongodb://127.0.0.1:14017"
> redis = "redis://127.0.0.1:14079/"
>
> [rabbit]
> port = 14072
> ```
>
> And mise configuration
>
> ```bash
> #.env
> DATABASE_PORT = "14017"
> RABBIT_PORT = "14072"
> REDIS_PORT = "14079"
> ```
Then continue:
```bash
cp livekit.example.yml livekit.yml
mise start
```
You can start a web client by doing the following in another terminal:
```bash
# if you do not have yarn yet and have a modern Node.js:
corepack enable
# clone the web client and run it:
git clone --recursive https://github.com/stoatchat/for-web stoat-web
cd stoat-web
# refer to stoat-web/README.md for startup, creating an account and loging in
```
When signing up, go to http://localhost:14080 to find confirmation/password reset emails.
To stop all services, hit (CTRL + c) in the terminal you ran `mise start` and run `mise docker:stop`
## Deployment Guide
### Cutting new crate releases
Begin by bumping crate versions:
```bash
just patch # 0.0.X
just minor # 0.X.0
just major # X.0.0
```
Then commit the changes to package files.
Proceed to publish all the new crates:
```bash
just publish
```
### Cutting new binary releases
Tag and push a new release by running:
```bash
just release
```
If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/stoatchat/stoatchat/releases/new) to create a changelog.
## Testing
First, start the required services:
```sh
docker compose up -d
```
Now run tests for whichever database:
```sh
TEST_DB=REFERENCE cargo nextest run
TEST_DB=MONGODB cargo nextest run
```
The contribution guide is located at [developers.revolt.chat/contributing](https://developers.revolt.chat/contributing).
Please note that a pull request should only take care of one issue so that we can review it quickly.
## License
The Stoat backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/stoatchat/stoatchat/blob/main/LICENSE).
**Individual crates may supply their own licenses!**
The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE). Please check individual crates for further license information.

View File

@@ -1,70 +0,0 @@
# ⚠️ This configuration is intended for development environment.
# If you'd like to override anything, create a Revolt.overrides.toml
[database]
# MongoDB connection URL
# Defaults to the container name specified in self-hosted
mongodb = "mongodb://127.0.0.1:27017"
# Redis connection URL
# Defaults to the container name specified in self-hosted
redis = "redis://127.0.0.1:6379/"
[rabbit]
host = "127.0.0.1"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat:14701"
api = "http://local.revolt.chat:14702"
events = "ws://local.revolt.chat:14703"
autumn = "http://local.revolt.chat:14704"
january = "http://local.revolt.chat:14705"
voso_legacy = ""
voso_legacy_ws = ""
# Public urls for livekit nodes
# each entry here should have a corresponding entry under `api.livekit.nodes`
[hosts.livekit]
worldwide = "ws://local.revolt.chat:14706"
[api]
[api.smtp]
# Email server configuration for verification
# Defaults to no email verification (host field is empty)
host = "localhost"
username = "smtp"
password = "smtp"
from_address = "development@revolt.chat"
reply_to = "support@revolt.chat"
port = 14025
use_tls = false
use_starttls = false
[api.livekit]
# Config for livekit nodes
# Make sure to change the secret when deploying
# The key and secret should match the values livekit is using
[api.livekit.nodes.worldwide]
url = "http://livekit"
lat = 0.0
lon = 0.0
key = "worldwide"
secret = "ZjCofRlfm6GGtjlifmNpCDkcQbEIIVC0"
[files.s3]
# S3 protocol endpoint
endpoint = "http://127.0.0.1:14009"
# S3 region name
region = "minio"
# S3 protocol key ID
access_key_id = "minioautumn"
# S3 protocol access key
secret_access_key = "minioautumn"
# Bucket to upload to by default
default_bucket = "revolt-uploads"

View File

@@ -1,28 +0,0 @@
disallowed-methods = [
# Shouldn't need to access these directly
"revolt_database::models::bots::model::Bot::remove_field",
"revolt_database::models::messages::model::Message::attach_sendable_embed",
"revolt_database::models::users::model::User::set_relationship",
"revolt_database::models::users::model::User::apply_relationship",
# Prefer to use Object::create()
"revolt_database::models::bots::ops::AbstractBots::insert_bot",
"revolt_database::models::channel_invites::ops::AbstractChannelInvites::insert_invite",
"revolt_database::models::channel_unreads::ops::AbstractChannelUnreads::acknowledge_message",
"revolt_database::models::channel_webhooks::ops::AbstractWebhooks::insert_webhook",
"revolt_database::models::channels::ops::AbstractChannels::insert_channel",
"revolt_database::models::emojis::ops::AbstractEmojis::insert_emoji",
"revolt_database::models::files::ops::AbstractAttachments::insert_attachment",
"revolt_database::models::messages::ops::AbstractMessages::insert_message",
"revolt_database::models::ratelimit_events::ops::AbstractRatelimitEvents::insert_ratelimit_event",
"revolt_database::models::server_bans::ops::AbstractServerBans::insert_ban",
"revolt_database::models::server_members::ops::AbstractServerMembers::insert_member",
"revolt_database::models::servers::ops::AbstractServers::insert_server",
"revolt_database::models::users::ops::AbstractUsers::insert_user",
# Prefer to use Object::update(&self)
"revolt_database::models::bots::ops::AbstractBots::update_bot",
# Prefer to use Object::delete(&self)
"revolt_database::models::bots::ops::AbstractBots::delete_bot",
]

View File

@@ -1,88 +0,0 @@
services:
# Redis
redis:
image: eqalpha/keydb
ports:
- "6379:6379"
# MongoDB
database:
image: mongo
command: mongod --replSet rs0
ports:
- "27017:27017"
volumes:
- ./.data/db:/data/db
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
ulimits:
nofile:
soft: 65536
hard: 65536
# MinIO
minio:
image: firstfinger/minio:latest
#command: server /data
environment:
MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn
MINIO_REGION: minio
volumes:
- ./.data/minio:/data
ports:
- "14009:9000"
- "14010:9001"
restart: always
# Create buckets for minio.
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
/bin/sh -c "while ! /usr/bin/mc ready minio; do
/usr/bin/mc alias set minio http://minio:9000 minioautumn minioautumn;
echo 'Waiting minio...' && sleep 1;
done; /usr/bin/mc mb minio/revolt-uploads; exit 0;"
# Rabbit
rabbit:
image: rabbitmq:4-management
environment:
RABBITMQ_DEFAULT_USER: rabbituser
RABBITMQ_DEFAULT_PASS: rabbitpass
volumes:
- ./.data/rabbit:/var/lib/rabbitmq
#- ./rabbit_plugins:/opt/rabbitmq/plugins/
#- ./rabbit_enabled_plugins:/etc/rabbitmq/enabled_plugins
# uncomment this if you need to enable other plugins
ports:
- "5672:5672"
- "15672:15672" # management UI, for development
# Mock SMTP server
maildev:
image: maildev/maildev
ports:
- "14025:25"
- "14080:8080"
environment:
MAILDEV_SMTP_PORT: 25
MAILDEV_WEB_PORT: 8080
MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp
livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.13
command: --config /etc/livekit.yml
network_mode: "host"
volumes:
- ./livekit.yml:/etc/livekit.yml

View File

@@ -1,45 +1,28 @@
[package]
name = "revolt-bonfire"
version = "0.13.7"
version = "0.5.19"
license = "AGPL-3.0-or-later"
edition = "2021"
publish = false
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
# util
log = { workspace = true }
sentry = { workspace = true }
lru = { workspace = true }
ulid = { workspace = true }
once_cell = { workspace = true }
redis-kiss = { workspace = true }
lru_time_cache = { workspace = true }
async-channel = { workspace = true }
log = "*"
once_cell = "1.9.0"
# parsing
querystring = { workspace = true }
regex = { workspace = true }
querystring = "1.1.0"
# quark
revolt-quark = { path = "../quark" }
# serde
bincode = { workspace = true }
serde_json = { workspace = true }
rmp-serde = { workspace = true }
serde = { workspace = true }
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = "1.0.136"
# async
futures = { workspace = true }
async-tungstenite = { workspace = true, features = ["async-std-runtime"] }
async-std = { workspace = true }
# core
revolt-result = { workspace = true }
revolt-models = { workspace = true }
revolt-config = { workspace = true }
revolt-database = { workspace = true, features = ["voice"] }
revolt-permissions = { workspace = true }
revolt-presence = { workspace = true, features = ["redis-is-patched"] }
# redis
fred = { workspace = true, features = ["subscriber-client"] }
futures = "0.3.21"
async-tungstenite = { version = "0.17.0", features = ["async-std-runtime"] }
async-std = { version = "1.8.0", features = ["tokio1", "tokio02", "attributes"] }

View File

@@ -1,12 +1,11 @@
# Build Stage
FROM ghcr.io/stoatchat/base:latest AS builder
FROM debian:12 AS debian
FROM ghcr.io/revoltchat/base:latest AS builder
# Bundle Stage
FROM gcr.io/distroless/cc-debian12:nonroot
FROM debian:bullseye-slim
RUN apt-get update && \
apt-get install -y ca-certificates && \
apt-get clean
COPY --from=builder /home/rust/src/target/release/revolt-bonfire ./
COPY --from=debian /usr/bin/uname /usr/bin/uname
EXPOSE 14703
USER nonroot
EXPOSE 9000
CMD ["./revolt-bonfire"]

View File

@@ -1,15 +1,8 @@
use async_tungstenite::tungstenite::{handshake, Message};
use futures::channel::oneshot::Sender;
use once_cell::sync::Lazy;
use regex::Regex;
use revolt_database::events::client::ReadyPayloadFields;
use revolt_result::{create_error, Result};
use revolt_quark::{Error, Result};
use serde::{Deserialize, Serialize};
/// matches either a single word ie "users" or a key and value ie "settings[notifications]"
static READY_PAYLOAD_FIELD_REGEX: Lazy<Regex> =
Lazy::new(|| Regex::new(r#"^(\w+)(?:\[(\S+)\])?$"#).unwrap());
/// Enumeration of supported protocol formats
#[derive(Debug)]
pub enum ProtocolFormat {
@@ -23,7 +16,6 @@ pub struct ProtocolConfiguration {
protocol_version: i32,
format: ProtocolFormat,
session_token: Option<String>,
ready_payload_fields: ReadyPayloadFields,
}
impl ProtocolConfiguration {
@@ -32,13 +24,11 @@ impl ProtocolConfiguration {
protocol_version: i32,
format: ProtocolFormat,
session_token: Option<String>,
ready_payload_fields: ReadyPayloadFields,
) -> Self {
Self {
protocol_version,
format,
session_token,
ready_payload_fields,
}
}
@@ -47,16 +37,16 @@ impl ProtocolConfiguration {
match self.format {
ProtocolFormat::Json => {
if let Message::Text(text) = msg {
serde_json::from_str(text).map_err(|_| create_error!(InternalError))
serde_json::from_str(text).map_err(|_| Error::InternalError)
} else {
Err(create_error!(InternalError))
Err(Error::InternalError)
}
}
ProtocolFormat::Msgpack => {
if let Message::Binary(buf) = msg {
rmp_serde::from_slice(buf).map_err(|_| create_error!(InternalError))
rmp_serde::from_slice(buf).map_err(|_| Error::InternalError)
} else {
Err(create_error!(InternalError))
Err(Error::InternalError)
}
}
}
@@ -93,11 +83,6 @@ impl ProtocolConfiguration {
pub fn get_protocol_format(&self) -> &ProtocolFormat {
&self.format
}
/// Get ready payload fields
pub fn get_ready_payload_fields(&self) -> &ReadyPayloadFields {
&self.ready_payload_fields
}
}
/// Object holding one side of a channel for receiving the parsed information
@@ -127,23 +112,6 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
let mut protocol_version = 1;
let mut format = ProtocolFormat::Json;
let mut session_token = None;
let mut ready_payload_fields = if params.iter().any(|(k, _)| *k == "ready") {
// If they pass the ready field, set all fields to false
ReadyPayloadFields {
users: false,
servers: false,
channels: false,
members: false,
emojis: false,
voice_states: false,
user_settings: Vec::new(),
channel_unreads: false,
policy_changes: false,
}
} else {
ReadyPayloadFields::default()
};
// Parse and map parameters from key-value to known variables.
for (key, value) in params {
@@ -159,31 +127,6 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
_ => {}
},
"token" => session_token = Some(value.into()),
"ready" => {
// Re-enable all the fields the client specifies
if let Some(captures) = READY_PAYLOAD_FIELD_REGEX.captures(value) {
if let Some(field) = captures.get(0) {
match field.as_str() {
"users" => ready_payload_fields.users = true,
"servers" => ready_payload_fields.servers = true,
"channels" => ready_payload_fields.channels = true,
"members" => ready_payload_fields.members = true,
"emojis" => ready_payload_fields.emojis = true,
"voice_states" => ready_payload_fields.voice_states = true,
"channel_unreads" => ready_payload_fields.channel_unreads = true,
"user_settings" => {
if let Some(subkey) = captures.get(1) {
ready_payload_fields
.user_settings
.push(subkey.as_str().to_string());
}
}
"policy_changes" => ready_payload_fields.policy_changes = true,
_ => {}
}
}
}
}
_ => {}
}
}
@@ -196,7 +139,6 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
protocol_version,
format,
session_token,
ready_payload_fields,
})
.is_ok()
{

View File

@@ -1,5 +1,5 @@
use once_cell::sync::OnceCell;
use revolt_database::{Database, DatabaseInfo};
use revolt_quark::{Database, DatabaseInfo};
static DBCONN: OnceCell<Database> = OnceCell::new();
@@ -10,9 +10,7 @@ pub async fn connect() {
.await
.expect("Failed to connect to the database.");
if DBCONN.set(database).is_err() {
panic!("couldn't set database")
}
DBCONN.set(database).expect("Setting `Database`");
}
/// Get a reference to the current database.

View File

@@ -1,2 +0,0 @@
pub mod r#impl;
pub mod state;

View File

@@ -1,209 +0,0 @@
use std::{
collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, time::Duration
};
use async_std::sync::{Mutex, RwLock};
use lru::LruCache;
use lru_time_cache::{LruCache as LruTimeCache, TimedEntry};
use revolt_database::{Channel, Member, Server, User};
/// Enumeration representing some change in subscriptions
pub enum SubscriptionStateChange {
/// No change
None,
/// Clear all subscriptions
Reset,
/// Append or remove subscriptions
Change {
add: Vec<String>,
remove: Vec<String>,
},
}
/// Dumb per-state cache implementation
///
/// Ideally this would use a global cache that
/// allows for mutations and could use Rc<> to
/// track usage. If Rc<> == 1, then it only
/// remains in global cache, hence should be
/// dropped.
///
/// ------------------------------------------------
/// We can strip these objects to core information!!
/// ------------------------------------------------
#[derive(Debug)]
pub struct Cache {
pub user_id: String,
pub is_bot: bool,
pub users: HashMap<String, User>,
pub channels: HashMap<String, Channel>,
pub members: HashMap<String, Member>,
pub servers: HashMap<String, Server>,
pub seen_events: LruCache<String, ()>,
}
impl Default for Cache {
fn default() -> Self {
Cache {
user_id: Default::default(),
is_bot: false,
users: Default::default(),
channels: Default::default(),
members: Default::default(),
servers: Default::default(),
seen_events: LruCache::new(NonZeroUsize::new(20).unwrap()),
}
}
}
/// Client state
pub struct State {
pub cache: Cache,
pub session_id: String,
pub private_topic: String,
pub state: SubscriptionStateChange,
pub subscribed: Arc<RwLock<HashSet<String>>>,
pub active_servers: Arc<Mutex<LruTimeCache<String, ()>>>,
}
impl State {
/// Create state from User
pub fn from(user: User, session_id: String) -> State {
let mut subscribed = HashSet::new();
let private_topic = format!("{}!", user.id);
subscribed.insert(private_topic.clone());
subscribed.insert(user.id.clone());
let mut cache: Cache = Cache {
user_id: user.id.clone(),
..Default::default()
};
cache.users.insert(user.id.clone(), user);
State {
cache,
subscribed: Arc::new(RwLock::new(subscribed)),
active_servers: Arc::new(Mutex::new(LruTimeCache::with_expiry_duration_and_capacity(
Duration::from_secs(900),
5,
))),
session_id,
private_topic,
state: SubscriptionStateChange::Reset,
}
}
/// Apply currently queued state
pub async fn apply_state(&mut self) -> SubscriptionStateChange {
// Check if we need to change subscriptions to member event topics
if !self.cache.is_bot {
enum Server {
Subscribe(String),
Unsubscribe(String),
}
let active_server_changes: Vec<Server> = {
let mut active_servers = self.active_servers.lock().await;
active_servers
.notify_iter()
.map(|e| match e {
TimedEntry::Valid(k, _) => Server::Subscribe(format!("{}u", k)),
TimedEntry::Expired(k, _) => Server::Unsubscribe(format!("{}u", k)),
})
.collect()
// It is bad practice to open more than one Mutex at once and could
// lead to a deadlock, so instead we choose to collect the changes.
};
for entry in active_server_changes {
match entry {
Server::Subscribe(k) => {
self.insert_subscription(k).await;
}
Server::Unsubscribe(k) => {
self.remove_subscription(&k).await;
}
}
}
}
// Flush changes to subscriptions
let state = std::mem::replace(&mut self.state, SubscriptionStateChange::None);
let mut subscribed = self.subscribed.write().await;
if let SubscriptionStateChange::Change { add, remove } = &state {
for id in add {
subscribed.insert(id.clone());
}
for id in remove {
subscribed.remove(id);
}
}
state
}
/// Clone the active user
pub fn clone_user(&self) -> User {
self.cache.users.get(&self.cache.user_id).unwrap().clone()
}
/// Reset the current state
pub async fn reset_state(&mut self) {
self.state = SubscriptionStateChange::Reset;
self.subscribed.write().await.clear();
}
/// Add a new subscription
pub async fn insert_subscription(&mut self, subscription: String) {
let mut subscribed = self.subscribed.write().await;
if subscribed.contains(&subscription) {
return;
}
match &mut self.state {
SubscriptionStateChange::None => {
self.state = SubscriptionStateChange::Change {
add: vec![subscription.clone()],
remove: vec![],
};
}
SubscriptionStateChange::Change { add, .. } => {
add.push(subscription.clone());
}
SubscriptionStateChange::Reset => {}
}
subscribed.insert(subscription);
}
/// Remove existing subscription
pub async fn remove_subscription(&mut self, subscription: &str) {
let mut subscribed = self.subscribed.write().await;
if !subscribed.contains(&subscription.to_string()) {
return;
}
match &mut self.state {
SubscriptionStateChange::None => {
self.state = SubscriptionStateChange::Change {
add: vec![],
remove: vec![subscription.to_string()],
};
}
SubscriptionStateChange::Change { remove, .. } => {
remove.push(subscription.to_string());
}
SubscriptionStateChange::Reset => panic!("Should not remove during a reset!"),
}
subscribed.remove(subscription);
}
}

View File

@@ -1,13 +1,12 @@
use std::env;
use async_std::net::TcpListener;
use revolt_presence::clear_region;
use revolt_quark::presence::presence_clear_region;
#[macro_use]
extern crate log;
pub mod config;
pub mod events;
mod database;
mod websocket;
@@ -15,28 +14,21 @@ mod websocket;
#[async_std::main]
async fn main() {
// Configure requirements for Bonfire.
revolt_config::configure!(events);
revolt_quark::configure!();
database::connect().await;
// Clean up the current region information.
let no_clear_region = env::var("NO_CLEAR_PRESENCE").unwrap_or_else(|_| "0".into()) == "1";
if !no_clear_region {
clear_region(None).await;
}
presence_clear_region(None).await;
// Setup a TCP listener to accept WebSocket connections on.
// By default, we bind to port 14703 on all interfaces.
let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:14703".into());
// By default, we bind to port 9000 on all interfaces.
let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9000".into());
info!("Listening on host {bind}");
let try_socket = TcpListener::bind(bind).await;
let listener = try_socket.expect("Failed to bind");
// Start accepting new connections and spawn a client for each connection.
while let Ok((stream, addr)) = listener.accept().await {
async_std::task::spawn(async move {
info!("User connected from {addr:?}");
websocket::client(database::get_db(), stream, addr).await;
info!("User disconnected from {addr:?}");
});
websocket::spawn_client(database::get_db(), stream, addr);
}
}

View File

@@ -1,575 +1,253 @@
use std::{collections::HashSet, net::SocketAddr, sync::Arc};
use std::net::SocketAddr;
use async_tungstenite::WebSocketStream;
use fred::{
error::RedisErrorKind,
interfaces::{ClientLike, EventInterface, PubsubInterface},
types::{ReconnectPolicy, RedisConfig},
use futures::{channel::oneshot, pin_mut, select, FutureExt, SinkExt, StreamExt, TryStreamExt};
use revolt_quark::{
events::{
client::EventV1,
server::ClientMessage,
state::{State, SubscriptionStateChange},
},
models::{user::UserHint, User},
presence::{presence_create_session, presence_delete_session},
redis_kiss, Database,
};
use futures::{
channel::oneshot,
join, pin_mut, select,
stream::{SplitSink, SplitStream},
FutureExt, SinkExt, StreamExt, TryStreamExt,
};
use redis_kiss::{get_connection, AsyncCommands, PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_config::report_internal_error;
use revolt_database::{
events::{client::EventV1, server::ClientMessage},
iso8601_timestamp::Timestamp,
Database, User, UserHint,
};
use revolt_presence::{create_session, delete_session};
use async_std::{
net::TcpStream,
sync::{Mutex, RwLock},
task::spawn,
};
use revolt_result::create_error;
use sentry::Level;
use async_std::{net::TcpStream, sync::Mutex, task};
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange};
use revolt_models::v0;
use crate::config::WebsocketHandshakeCallback;
type WsReader = SplitStream<WebSocketStream<TcpStream>>;
type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>;
/// Start a new WebSocket client worker given access to the database,
/// Spawn a new WebSocket client worker given access to the database,
/// the relevant TCP stream and the remote address of the client.
pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr) {
// Upgrade the TCP connection to a WebSocket connection.
// In this process, we also parse any additional parameters given.
// e.g. wss://example.com?format=json&version=1
let (sender, receiver) = oneshot::channel();
let Ok(ws) = async_tungstenite::accept_hdr_async_with_config(
stream,
WebsocketHandshakeCallback::from(sender),
None,
)
.await
else {
return;
};
pub fn spawn_client(db: &'static Database, stream: TcpStream, addr: SocketAddr) {
// Spawn a new Async task to work on.
task::spawn(async move {
info!("User connected from {addr:?}");
// Verify we've received a valid config, otherwise we should just drop the connection.
let Ok(mut config) = receiver.await else {
return;
};
info!(
"User {addr:?} provided protocol configuration (version = {}, format = {:?})",
config.get_protocol_version(),
config.get_protocol_format()
);
// Split the socket for simultaneously read and write.
let (mut write, mut read) = ws.split();
// If the user has not provided authentication, request information.
if config.get_session_token().is_none() {
while let Ok(Some(message)) = read.try_next().await {
if let Ok(ClientMessage::Authenticate { token }) = config.decode(&message) {
config.set_session_token(token);
break;
}
}
}
// Try to authenticate the user.
let Some(token) = config.get_session_token().as_ref() else {
write
.send(config.encode(&EventV1::Error {
data: create_error!(InvalidSession),
}))
.await
.ok();
return;
};
let (user, session_id) = match User::from_token(db, token, UserHint::Any).await {
Ok(user) => user,
Err(err) => {
write
.send(config.encode(&EventV1::Error { data: err }))
.await
.ok();
return;
}
};
info!("User {addr:?} authenticated as @{}", user.username);
db.update_session_last_seen(&session_id, Timestamp::now_utc())
// Upgrade the TCP connection to a WebSocket connection.
// In this process, we also parse any additional parameters given.
// e.g. wss://example.com?format=json&version=1
let (sender, receiver) = oneshot::channel();
if let Ok(ws) = async_tungstenite::accept_hdr_async_with_config(
stream,
WebsocketHandshakeCallback::from(sender),
None,
)
.await
.ok();
{
// Verify we've received a valid config, otherwise we should just drop the connection.
if let Ok(mut config) = receiver.await {
info!(
"User {addr:?} provided protocol configuration (version = {}, format = {:?})",
config.get_protocol_version(),
config.get_protocol_format()
);
// Create local state.
let mut state = State::from(user, session_id);
let user_id = state.cache.user_id.clone();
// Split the socket for simultaneously read and write.
let (write, mut read) = ws.split();
let write = Mutex::new(write);
// Notify socket we have authenticated.
if report_internal_error!(write.send(config.encode(&EventV1::Authenticated)).await).is_err() {
return;
}
// Download required data to local cache and send Ready payload.
let ready_payload = match report_internal_error!(
state
.generate_ready_payload(db, config.get_ready_payload_fields())
.await
) {
Ok(ready_payload) => ready_payload,
Err(_) => return,
};
if report_internal_error!(write.send(config.encode(&ready_payload)).await).is_err() {
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.
let (first_session, session_id) = create_session(&user_id, 0).await;
// If this was the first session, notify other users that we just went online.
if first_session {
state.broadcast_presence_change(true).await;
}
{
// Setup channels and mutexes
let write = Mutex::new(write);
let subscribed = state.subscribed.clone();
let active_servers = state.active_servers.clone();
let (topic_signal_s, topic_signal_r) = async_channel::unbounded();
// TODO: this needs to be rewritten
// Create channels through which the tasks can signal to each other they need to clean up
let (kill_signal_1_s, kill_signal_1_r) = async_channel::bounded(1);
let (kill_signal_2_s, kill_signal_2_r) = async_channel::bounded(1);
// Create a PubSub connection to poll on.
let listener = listener_with_kill_signal(
db,
&mut state,
addr,
&config,
topic_signal_r,
kill_signal_1_r,
&write,
kill_signal_2_s,
);
// Read from WebSocket stream.
let worker = worker_with_kill_signal(
addr,
subscribed,
active_servers,
user_id.clone(),
&config,
topic_signal_s,
kill_signal_2_r,
read,
&write,
kill_signal_1_s,
);
join!(listener, worker);
}
// Clean up presence session.
let last_session = delete_session(&user_id, session_id).await;
// If this was the last session, notify other users that we just went offline.
if last_session {
state.broadcast_presence_change(false).await;
}
}
#[allow(clippy::too_many_arguments)]
async fn listener_with_kill_signal(
db: &'static Database,
state: &mut State,
addr: SocketAddr,
config: &ProtocolConfiguration,
topic_signal_r: async_channel::Receiver<()>,
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>,
kill_signal_s: async_channel::Sender<()>,
) {
listener(
db,
state,
addr,
config,
topic_signal_r,
kill_signal_r,
write,
)
.await;
kill_signal_s.send(()).await.ok();
}
async fn listener(
db: &'static Database,
state: &mut State,
addr: SocketAddr,
config: &ProtocolConfiguration,
topic_signal_r: async_channel::Receiver<()>,
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>,
) {
let stoat_config = revolt_config::config().await;
let url = stoat_config
.database
.redis_pubsub
.unwrap_or(REDIS_URI.to_string());
let redis_config = RedisConfig::from_url(&url).unwrap();
let mut builder = fred::types::Builder::from_config(redis_config);
builder.set_policy(ReconnectPolicy::new_exponential(8, 100, 30_000, 2));
let subscriber = match report_internal_error!(builder.build_subscriber_client()) {
Ok(subscriber) => subscriber,
Err(_) => return,
};
if report_internal_error!(subscriber.init().await).is_err() {
return;
}
// Let Fred automatically re-subscribe to tracked channels on reconnect.
subscriber.manage_subscriptions();
// Handle Redis connection dropping
let (clean_up_s, clean_up_r) = async_channel::bounded(1);
let clean_up_s = Arc::new(Mutex::new(clean_up_s));
subscriber.on_error(move |err| {
warn!("Redis subscriber error: {:?}", err);
if let RedisErrorKind::Canceled = err.kind() {
let clean_up_s = clean_up_s.clone();
spawn(async move {
clean_up_s.lock().await.send(()).await.ok();
});
}
// Transient errors (IO, timeout) are handled by the reconnect policy.
Ok(())
});
let mut message_rx = subscriber.message_rx();
'out: loop {
// Check for state changes for subscriptions.
match state.apply_state().await {
SubscriptionStateChange::Reset => {
if report_internal_error!(subscriber.unsubscribe_all().await).is_err() {
break 'out;
}
let subscribed = state.subscribed.read().await;
for id in subscribed.iter() {
if report_internal_error!(subscriber.subscribe(id).await).is_err() {
break 'out;
// If the user has not provided authentication, request information.
if config.get_session_token().is_none() {
'outer: while let Ok(message) = read.try_next().await {
if let Ok(ClientMessage::Authenticate { token }) =
config.decode(message.as_ref().unwrap())
{
config.set_session_token(token);
break 'outer;
}
}
}
#[cfg(debug_assertions)]
info!("{addr:?} has reset their subscriptions");
}
SubscriptionStateChange::Change { add, remove } => {
for id in remove {
#[cfg(debug_assertions)]
info!("{addr:?} unsubscribing from {id}");
// Try to authenticate the user.
if let Some(token) = config.get_session_token().as_ref() {
match User::from_token(db, token, UserHint::Any).await {
Ok(user) => {
info!("User {addr:?} authenticated as @{}", user.username);
if report_internal_error!(subscriber.unsubscribe(id).await).is_err() {
break 'out;
}
}
// Create local state.
let mut state = State::from(user);
let user_id = state.cache.user_id.clone();
for id in add {
#[cfg(debug_assertions)]
info!("{addr:?} subscribing to {id}");
// Create presence session.
let (first_session, session_id) =
presence_create_session(&user_id, 0).await;
if report_internal_error!(subscriber.subscribe(id).await).is_err() {
break 'out;
}
}
}
SubscriptionStateChange::None => {}
}
let t1 = message_rx.recv().fuse();
let t2 = topic_signal_r.recv().fuse();
let t3 = kill_signal_r.recv().fuse();
let t4 = clean_up_r.recv().fuse();
pin_mut!(t1, t2, t3, t4);
select! {
_ = t4 => {
break 'out;
},
_ = t3 => {
break 'out;
},
_ = t2 => {},
message = t1 => {
// Handle incoming events.
let message = match report_internal_error!(message) {
Ok(message) => message,
Err(_) => break 'out
};
let event = match *REDIS_PAYLOAD_TYPE {
PayloadType::Json => message
.value
.as_str()
.and_then(|s| report_internal_error!(serde_json::from_str::<EventV1>(s.as_ref())).ok()),
PayloadType::Msgpack => message
.value
.as_bytes()
.and_then(|b| report_internal_error!(rmp_serde::from_slice::<EventV1>(b)).ok()),
PayloadType::Bincode => message
.value
.as_bytes()
.and_then(|b| report_internal_error!(bincode::deserialize::<EventV1>(b)).ok()),
};
let Some(mut event) = event else {
let err = format!(
"Failed to deserialise event for {}: `{:?}`",
message.channel,
message
.value
);
error!("{}", err);
sentry::capture_message(&err, Level::Error);
break 'out;
};
if let EventV1::DeleteSession { session_id, .. } = &event {
if &state.session_id == session_id {
event = EventV1::Logout;
}
} else if let EventV1::DeleteAllSessions {
exclude_session_id, ..
} = &event
{
if let Some(excluded) = exclude_session_id {
if &state.session_id != excluded {
event = EventV1::Logout;
}
} else {
event = EventV1::Logout;
}
} else {
let should_send = state.handle_incoming_event_v1(db, &mut event).await;
if !should_send {
continue;
}
}
let result = write.lock().await.send(config.encode(&event)).await;
if let Err(e) = result {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
let err = format!("Error while sending an event to {addr:?}: {e:?}");
warn!("{}", err);
sentry::capture_message(&err, Level::Warning);
}
break 'out;
}
if let EventV1::Logout = event {
info!("User {addr:?} received log out event!");
break 'out;
}
}
}
}
report_internal_error!(subscriber.quit().await).ok();
}
#[allow(clippy::too_many_arguments)]
async fn worker_with_kill_signal(
addr: SocketAddr,
subscribed: Arc<RwLock<HashSet<String>>>,
active_servers: Arc<Mutex<lru_time_cache::LruCache<String, ()>>>,
user_id: String,
config: &ProtocolConfiguration,
topic_signal_s: async_channel::Sender<()>,
kill_signal_r: async_channel::Receiver<()>,
read: WsReader,
write: &Mutex<WsWriter>,
kill_signal_s: async_channel::Sender<()>,
) {
worker(
addr,
subscribed,
active_servers,
user_id,
config,
topic_signal_s,
kill_signal_r,
read,
write,
)
.await;
kill_signal_s.send(()).await.ok();
}
#[allow(clippy::too_many_arguments)]
async fn worker(
addr: SocketAddr,
subscribed: Arc<RwLock<HashSet<String>>>,
active_servers: Arc<Mutex<lru_time_cache::LruCache<String, ()>>>,
user_id: String,
config: &ProtocolConfiguration,
topic_signal_s: async_channel::Sender<()>,
kill_signal_r: async_channel::Receiver<()>,
mut read: WsReader,
write: &Mutex<WsWriter>,
) {
let revolt_config = revolt_config::config().await;
loop {
let t1 = read.try_next().fuse();
let t2 = kill_signal_r.recv().fuse();
pin_mut!(t1, t2);
select! {
_ = t2 => {
return;
},
result = t1 => {
let msg = match result {
Ok(Some(msg)) => msg,
Ok(None) => {
warn!("Received a None message!");
sentry::capture_message("Received a None message!", Level::Warning);
return;
}
Err(e) => {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
let err = format!("Error while reading an event from {addr:?}: {e:?}");
warn!("{}", err);
sentry::capture_message(&err, Level::Warning);
}
return;
}
};
let Ok(payload) = config.decode(&msg) else {
continue;
};
match payload {
ClientMessage::BeginTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
EventV1::ChannelStartTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::EndTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
EventV1::ChannelStopTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::Subscribe { server_id } => {
let mut servers = active_servers.lock().await;
let has_item = servers.contains_key(&server_id);
servers.insert(server_id, ());
if !has_item {
// Poke the listener to adjust subscriptions
topic_signal_s.send(()).await.ok();
}
}
ClientMessage::Ping { data, responded } => {
if responded.is_none() {
// Notify socket we have authenticated.
write
.lock()
.await
.send(config.encode(&EventV1::Pong { data }))
.send(config.encode(&EventV1::Authenticated))
.await
.ok();
// Download required data to local cache and send Ready payload.
if let Ok(ready_payload) = state.generate_ready_payload(db).await {
write
.lock()
.await
.send(config.encode(&ready_payload))
.await
.ok();
// If this was the first session, notify other users that we just went online.
if first_session {
state.broadcast_presence_change(true).await;
}
// Create a PubSub connection to poll on.
let listener = async {
if let Ok(mut conn) = redis_kiss::open_pubsub_connection().await
{
loop {
// Check for state changes for subscriptions.
match state.apply_state() {
SubscriptionStateChange::Reset => {
for id in state.iter_subscriptions() {
conn.subscribe(id).await.unwrap();
}
#[cfg(debug_assertions)]
info!("{addr:?} has reset their subscriptions");
}
SubscriptionStateChange::Change { add, remove } => {
for id in remove {
#[cfg(debug_assertions)]
info!("{addr:?} unsubscribing from {id}");
conn.unsubscribe(id).await.unwrap();
}
for id in add {
#[cfg(debug_assertions)]
info!("{addr:?} subscribing to {id}");
conn.subscribe(id).await.unwrap();
}
}
SubscriptionStateChange::None => {}
}
// * Debug logging of current subscriptions.
/*#[cfg(debug_assertions)]
info!(
"User {addr:?} is subscribed to {:?}",
state
.iter_subscriptions()
.collect::<Vec<&String>>()
);*/
// Handle incoming events.
match conn.on_message().next().await.map(|item| {
(
item.get_channel_name().to_string(),
redis_kiss::decode_payload::<EventV1>(&item),
)
}) {
Some((channel, item)) => {
if let Ok(mut event) = item {
if state
.handle_incoming_event_v1(
db, &mut event,
)
.await
&& write.lock().await
.send(config.encode(&event))
.await
.is_err()
{
break;
}
} else {
warn!("Failed to deserialise an event for {channel}!");
}
}
// No more data, assume we disconnected or otherwise
// something bad occurred, so disconnect user.
None => break,
}
}
}
}
.fuse();
// Read from WebSocket stream.
let worker =
async {
while let Ok(Some(msg)) = read.try_next().await {
if let Ok(payload) = config.decode(&msg) {
match payload {
ClientMessage::BeginTyping { channel } => {
EventV1::ChannelStartTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::EndTyping { channel } => {
EventV1::ChannelStopTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::Ping { data, responded } => {
if responded.is_none() {
write
.lock()
.await
.send(config.encode(
&EventV1::Pong { data },
))
.await
.ok();
}
}
_ => {}
}
}
}
}
.fuse();
// Pin both tasks.
pin_mut!(listener, worker);
// Wait for either disconnect or for listener to die.
select!(
() = listener => {},
() = worker => {}
);
// * Combine the streams back once we are ready to disconnect.
/* ws = read.reunite(write).unwrap(); */
}
// Clean up presence session.
let last_session = presence_delete_session(&user_id, session_id).await;
// If this was the last session, notify other users that we just went offline.
if last_session {
state.broadcast_presence_change(false).await;
}
}
Err(err) => {
write.lock().await.send(config.encode(&err)).await.ok();
}
}
_ => {}
}
}
// * Disconnect the WebSocket if it isn't already.
/*ws.close(Some(CloseFrame {
code: CloseCode::Normal,
reason: std::borrow::Cow::from(""),
}))
.await
.unwrap();*/
}
}
}
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)
info!("User disconnected from {addr:?}");
});
}

1294
crates/bridges/discord/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,10 @@
[package]
name = "revolt-discord-bridge"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
serenity = { version = "0.11.5", default-features = false, features = ["client", "gateway", "rustls_backend", "model"] }
tokio = { version = "1.0", features = ["macros", "rt-multi-thread"] }

View File

@@ -0,0 +1,45 @@
use std::env;
use serenity::async_trait;
use serenity::model::channel::Message;
use serenity::model::channel::Channel;
use serenity::model::gateway::{GatewayIntents, Ready};
use serenity::prelude::*;
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn message(&self, ctx: Context, msg: Message) {
println!("Received message: {}", msg.content);
if msg.content == "deez" {
if let Ok(Channel::Guild(channel)) = msg.channel(&ctx).await {
channel.send_message(&ctx, |m| {
m.content("this is where a message would usually go")
}).await.unwrap();
}
}
}
async fn ready(&self, _: Context, ready: Ready) {
println!("{} is connected!", ready.user.name);
}
}
#[tokio::main]
async fn main() {
let token = env::var("DISCORD_TOKEN").expect("Expected a token in the environment");
let intents =
GatewayIntents::GUILDS | GatewayIntents::GUILD_MESSAGES | GatewayIntents::MESSAGE_CONTENT;
let mut client = Client::builder(token, intents)
.event_handler(Handler)
.await
.expect("Error creating client");
if let Err(why) = client.start().await {
println!("Client error: {:?}", why);
}
}

View File

@@ -1,28 +0,0 @@
[package]
name = "revolt-coalesced"
version = "0.13.7"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>", "Zomatree <me@zomatree.live>"]
description = "Revolt Backend: Coalescion service"
repository = "https://github.com/stoatchat/stoatchat"
[features]
tokio = ["dep:tokio"]
queue = ["dep:indexmap"]
cache = ["dep:lru"]
default = ["tokio"]
[dependencies]
tokio = { workspace = true, features = ["sync"], optional = true }
indexmap = { workspace = true, optional = true }
lru = { workspace = true, optional = true }
[dev-dependencies]
tokio = { workspace = true, features = [
"rt",
"rt-multi-thread",
"macros",
"time",
] }

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2024 Pawel Makles
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,24 +0,0 @@
#[derive(Clone, PartialEq, Eq, Debug)]
/// Config values for [`CoalescionService`].
pub struct CoalescionServiceConfig {
/// How many tasks are running at once
pub max_concurrent: Option<usize>,
/// Whether to queue tasks once `max_concurrent` is reached
#[cfg(feature = "queue")]
pub queue_requests: bool,
/// Max amount of tasks in the buffer queue
#[cfg(feature = "queue")]
pub max_queue: Option<usize>,
}
impl Default for CoalescionServiceConfig {
fn default() -> Self {
Self {
max_concurrent: Some(100),
#[cfg(feature = "queue")]
queue_requests: true,
#[cfg(feature = "queue")]
max_queue: Some(100)
}
}
}

View File

@@ -1,27 +0,0 @@
use std::fmt;
#[derive(Clone, Copy, PartialEq, Eq, Debug, Hash)]
/// Coalescion service error.
pub enum Error {
/// Failed to receive the actions return from the channel for unknown reason
RecvError,
/// Reached the `max_concurrent` amount of actions running at once and could not queue the action
MaxConcurrent,
/// Reached the `max_queue` amount of actions in the queue
MaxQueue,
/// Failed to downcast the type to the current type being returned, this will be most likely an ID collision
DowncastError,
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::RecvError => write!(f, "Unable to receive data from the channel"),
Error::MaxConcurrent => write!(f, "Max number of tasks running at once"),
Error::MaxQueue => write!(f, "Max number of tasks in queue"),
Error::DowncastError => write!(f, "Failed to downcast type, possible key collision with different types")
}
}
}
impl std::error::Error for Error {}

View File

@@ -1,39 +0,0 @@
//! # Coalesced
//!
//! Coalescion service to group, caching and queue duplicate actions.
//! useful for deduplicating web requests, database lookups and other similar resource
//! intensive or rate-limited actions.
//!
//! ## Features
//! - `tokio`: Uses tokio for the async backend, this is currently the only backend.
//! - `queue`: Whether to support queueing requests to only allow X amount of actions running at once.
//! - `cache`: Whether to cache the actions results for future actions with the same id, uses an LRU cache internally.
//!
//! [`CoalescionService`] uses both [`Arc`] and [`RwLock`] internally and can be cheaply cloned to
//! use in your codebase.
//!
//! It is common practice to wrap the service and in your own which delegates the executions to ensure all ids are tracked in one location across your codebase.
//!
//! All values are stored using [`Any`] and must be [`'static`] + [`Send`] + [`Sync`], if there is an id mismatch
//! and a type is wrong the library will return an error, values returned from the service are also
//! wrapped in an [`Arc`] as they are shared to each duplicate action.
//!
//! ## Example:
//! ```rs
//! use revolt_coalesced::CoalescionService;
//!
//! let service = CoalescionService::new();
//!
//! let user_id = "my_user_id";
//! let user = service.execute(user_id, || async move {
//! database.fetch_user(user_id).await.unwrap()
//! }).await;
//! ```
mod config;
mod error;
mod service;
pub use config::CoalescionServiceConfig;
pub use error::Error;
pub use service::CoalescionService;

View File

@@ -1,208 +0,0 @@
use std::{any::Any, collections::HashMap, fmt::Debug, future::Future, hash::Hash, sync::Arc};
use tokio::sync::{
watch::{channel as watch_channel, Receiver},
RwLock,
};
#[cfg(feature = "cache")]
use lru::LruCache;
#[cfg(feature = "queue")]
use indexmap::IndexMap;
use crate::{CoalescionServiceConfig, Error};
#[derive(Debug, Clone)]
#[allow(clippy::type_complexity)]
/// # Coalescion service
///
/// See module description for example usage.
pub struct CoalescionService<Id: Hash + Clone + Eq> {
config: Arc<CoalescionServiceConfig>,
watchers: Arc<RwLock<HashMap<Id, Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>>>>,
#[cfg(feature = "queue")]
queue: Arc<RwLock<IndexMap<Id, Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>>>>,
#[cfg(feature = "cache")]
cache: Option<Arc<tokio::sync::Mutex<LruCache<Id, Arc<dyn Any + Send + Sync>>>>>,
}
impl<Id: Hash + Clone + Eq> CoalescionService<Id> {
pub fn new() -> Self {
Default::default()
}
pub fn from_config(config: CoalescionServiceConfig) -> Self {
Self {
config: Arc::new(config),
watchers: Arc::new(RwLock::new(HashMap::new())),
#[cfg(feature = "queue")]
queue: Arc::new(RwLock::new(IndexMap::new())),
#[cfg(feature = "cache")]
cache: None,
}
}
#[cfg(feature = "cache")]
pub fn from_cache(
config: CoalescionServiceConfig,
cache: LruCache<Id, Arc<dyn Any + Send + Sync>>,
) -> Self {
Self {
cache: Some(Arc::new(Mutex::new(cache))),
..Self::from_config(config)
}
}
async fn wait_for<Value: Any + Send + Sync>(
&self,
mut receiver: Receiver<Option<Result<Arc<dyn Any + Send + Sync>, Error>>>,
) -> Result<Arc<Value>, Error> {
receiver
.wait_for(|v| v.is_some())
.await
.map_err(|_| Error::RecvError)
.and_then(|r| r.clone().unwrap())
.and_then(|arc| Arc::downcast(arc).map_err(|_| Error::DowncastError))
}
async fn insert_and_execute<
Value: Send + Sync + 'static,
F: FnOnce() -> Fut,
Fut: Future<Output = Value>,
>(
&self,
id: Id,
func: F,
) -> Result<Arc<Value>, Error> {
let (send, recv) = watch_channel(None);
self.watchers.write().await.insert(id.clone(), recv);
let value = Ok(Arc::new(func().await));
send.send_modify(|opt| {
opt.replace(value.clone().map(|v| v as Arc<dyn Any + Send + Sync>));
});
#[cfg(feature = "cache")]
if let Some(cache) = self.cache.as_ref() {
if let Ok(value) = &value {
cache.lock().await.push(id.clone(), value.clone());
}
};
self.watchers.write().await.remove(&id);
value
}
/// Coalesces an function, the actual function may not run if one with the same id is already running,
/// queued to be ran, or cached, the id should be globally unique for this specific action.
pub async fn execute<
Value: Send + Sync + 'static,
F: FnOnce() -> Fut,
Fut: Future<Output = Value>,
>(
&self,
id: Id,
func: F,
) -> Result<Arc<Value>, Error> {
#[cfg(feature = "cache")]
if let Some(cache) = self.cache.as_ref() {
if let Some(value) = cache.lock().await.get(&id) {
return Arc::downcast::<Value>(value.clone()).map_err(|_| Error::DowncastError);
}
};
let (receiver, length) = {
let watchers = self.watchers.read().await;
let length = watchers.len();
(watchers.get(&id).cloned(), length)
};
if let Some(receiver) = receiver {
self.wait_for(receiver).await
} else {
match self.config.max_concurrent {
Some(max_concurrent) if length >= max_concurrent => {
#[cfg(feature = "queue")]
if self.config.queue_requests {
let (receiver, length) = {
let queue = self.queue.read().await;
(queue.get(&id).cloned(), queue.len())
};
if let Some(receiver) = receiver {
return self.wait_for(receiver).await;
} else {
if self
.config
.max_queue
.is_some_and(|max_queue| max_queue >= length)
{
return Err(Error::MaxQueue);
};
let (send, recv) = watch_channel(None);
self.queue.write().await.insert(id.clone(), recv);
loop {
let length = self.watchers.read().await.len();
if length < max_concurrent {
let first_key = {
let queue = self.queue.read().await;
queue.first().map(|v| v.0).cloned()
};
if first_key == Some(id.clone()) {
self.queue.write().await.shift_remove(&id);
let response = self.insert_and_execute(id, func).await;
send.send_modify(|opt| {
opt.replace(
response
.clone()
.map(|v| v as Arc<dyn Any + Send + Sync>),
);
});
return response;
}
}
}
}
} else {
Err(Error::MaxConcurrent)
}
#[cfg(not(feature = "queue"))]
Err(Error::MaxConcurrent)
}
_ => self.insert_and_execute(id, func).await,
}
}
}
/// Fetches the amount of currently running tasks
pub async fn current_task_count(&self) -> usize {
self.watchers.read().await.len()
}
#[cfg(feature = "queue")]
/// Fetches the current length of the queue
pub async fn current_queue_len(&self) -> usize {
self.queue.read().await.len()
}
}
impl<Id: Hash + Clone + Eq> Default for CoalescionService<Id> {
fn default() -> Self {
Self::from_config(CoalescionServiceConfig::default())
}
}

View File

@@ -1,40 +0,0 @@
[package]
name = "revolt-config"
version = "0.13.7"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Configuration"
repository = "https://github.com/stoatchat/stoatchat"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
anyhow = ["dep:sentry-anyhow"]
report-macros = ["revolt-result"]
sentry = ["dep:sentry"]
test = ["async-std"]
default = ["sentry"]
[dependencies]
# Utility
config = { workspace = true }
cached = { workspace = true }
# Serde
serde = { workspace = true }
# Async
futures-locks = { workspace = true }
async-std = { workspace = true, features = ["attributes"], optional = true }
# Logging
log = { workspace = true }
pretty_env_logger = { workspace = true }
# Sentry
sentry = { workspace = true, optional = true }
sentry-anyhow = { workspace = true, optional = true }
# Core
revolt-result = { workspace = true, optional = true }

View File

@@ -1,9 +0,0 @@
MIT License
Copyright (c) 2024 Pawel Makles
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -1,23 +0,0 @@
environment = "test"
[database]
mongodb = "mongodb://localhost"
redis = "redis://localhost/"
[rabbit]
host = "127.0.0.1"
port = 5672
username = "rabbituser"
password = "rabbitpass"
[features]
webhooks_enabled = true
[api.smtp]
host = "localhost"
username = "smtp"
password = "smtp"
from_address = "development@stoat.chat"
reply_to = "support@stoat.chat"
port = 14025
use_tls = false

View File

@@ -1,348 +0,0 @@
production = false
disable_events_dont_use = false
environment = "dev"
[database]
# MongoDB connection URL
# Defaults to the container name specified in self-hosted
mongodb = "mongodb://database"
# Redis connection URL
# Defaults to the container name specified in self-hosted
redis = "redis://redis/"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat"
api = "http://local.revolt.chat/api"
events = "ws://local.revolt.chat/ws"
autumn = "http://local.revolt.chat/autumn"
january = "http://local.revolt.chat/january"
voso_legacy = ""
voso_legacy_ws = ""
[hosts.livekit]
[rabbit]
host = "rabbit"
port = 5672
username = "rabbituser"
password = "rabbitpass"
default_exchange = "revolt.default"
[rabbit.queues]
acks = "internal.ack"
[api]
[api.registration]
# Whether an invite should be required for registration
# See https://github.com/stoatchat/self-hosted#making-your-instance-invite-only
invite_only = false
[api.smtp]
# Email server configuration for verification
# Defaults to no email verification (host field is empty)
host = ""
username = ""
password = ""
from_address = "noreply@example.com"
# reply_to = "noreply@example.com"
# port = 587
# use_tls = true
[api.smtp.expiry]
expire_verification = 604800 # 3600 * 24 * 7
expire_password_reset = 86400 # 3600 * 24
expire_account_deletion = 86400 # 3600 * 24
[api.security]
# Authifier Shield API key
authifier_shield_key = ""
# Legacy voice server management token
voso_legacy_token = ""
# Whether services are behind the Cloudflare network
trust_cloudflare = false
# easypwned endpoint
easypwned = ""
# Tenor API Key
tenor_key = ""
[api.security.captcha]
# hCaptcha configuration
hcaptcha_key = ""
hcaptcha_sitekey = ""
[api.security.shield]
host = ""
key = ""
[api.workers]
# Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50
[api.livekit]
# How long to ring devices for when calling in dms/groups, in seconds
call_ring_duration = 30
[api.livekit.nodes]
[api.users]
# Minimum allowed length of usernames
min_username_length = 2
[pushd]
# this changes the names of the queues to not overlap
# prod/beta if they happen to be on the same exchange/instance.
# Usually they have to be, so that messages sent from one or the other get sent to everyone
production = true
# Changes how many users are processed in each chunk when resolving role/everyone mentions.
# Increasing this will resolve mentions faster, but will consume more memory while resolving.
mass_mention_chunk_size = 200
# How long pushd will cache resolved names for rendered message notifications.
# Increasing this will result in lower database usage, but may result in a situation where a user/channel/role name changes
# and the notifications still resolve to the old name.
render_cache_time = 60
# none of these should need changing
exchange = "revolt.notifications"
message_queue = "notifications.origin.message"
mass_mention_queue = "notifications.origin.mass_mention" # handles messages that contain role or everyone mentions
fr_accepted_queue = "notifications.ingest.fr_accepted" # friend request accepted
fr_received_queue = "notifications.ingest.fr_received" # friend request received
dm_call_queue = "notifications.ingest.dm_call" # direct message voice call
generic_queue = "notifications.ingest.generic" # generic messages (title + body)
ack_queue = "notifications.process.ack" # updates badges for apple devices
[pushd.vapid]
queue = "notifications.outbound.vapid"
private_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUJSUWpyTWxLRnBiVWhsUHpUbERvcEliYk1yeVNrNXpKYzVYVzIxSjJDS3hvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFWnkrQkg2TGJQZ2hEa3pEempXOG0rUXVPM3pCajRXT1phdkR6ZU00c0pqbmFwd1psTFE0WAp1ZDh2TzVodU94QWhMQlU3WWRldVovWHlBdFpWZmNyQi9BPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo"
public_key = "BGcvgR-i2z4IQ5Mw841vJvkLjt8wY-FjmWrw83jOLCY52qcGZS0OF7nfLzuYbjsQISwVO2HXrmf18gLWVX3Kwfw="
[pushd.fcm]
queue = "notifications.outbound.fcm"
key_type = ""
project_id = ""
private_key_id = ""
private_key = ""
client_email = ""
client_id = ""
auth_uri = ""
token_uri = ""
auth_provider_x509_cert_url = ""
client_x509_cert_url = ""
[pushd.apn]
sandbox = false
queue = "notifications.outbound.apn"
pkcs8 = ""
key_id = ""
team_id = ""
[january]
blocked_domains = []
[files]
# Encryption key for stored files
# Generate your own key using `openssl rand -base64 32`
encryption_key = "qcuMA+ssxhMyKaNAKBGFfryfFtUH8NDlamQyDwGW6fU="
# Quality used for lossy WebP previews (set to 100 for lossless)
webp_quality = 80.0
# Mime types that cannot be uploaded or served
#
# Example for Windows executables and Android installation files:
# ["application/vnd.microsoft.portable-executable", "application/vnd.android.package-archive"]
blocked_mime_types = []
# ClamAV service
# hostname:port
clamd_host = ""
# Mime types that should be virus scanned
#
# Leave empty to scan all file types
scan_mime_types = [
"application/vnd.microsoft.portable-executable",
"application/vnd.android.package-archive",
"application/zip",
]
[files.limit]
# Minimum file size (in bytes)
min_file_size = 1
# Minimum image resolution
min_resolution = [1, 1]
# Maximum MP of images
max_mega_pixels = 40
# Maximum pixel side of an image
max_pixel_side = 10_000
[files.preview]
# Maximum image resolution
attachments = [1280, 1280]
avatars = [128, 128]
backgrounds = [1280, 720]
icons = [128, 128]
banners = [480, 480]
emojis = [128, 128]
[files.s3]
# Configuration for S3
# Defaults included for MinIO + self-hosted setup
#
# Backblaze B2:
# - endpoint is listed on the "Buckets" page
# - path_style_buckets is set to true
# - region is `eu-central-003` string from endpoint URL
# - access_key_id is keyID generated on the "Application Keys" page
# - secret_access_key is token generated on the "Application Keys" page
# - default_bucket matches the name of the bucket you've created
# S3 protocol endpoint
endpoint = "http://minio:9000"
# Whether to use path-style buckets
# Generally true, except for MinIO
path_style_buckets = false
# S3 region name
region = "minio"
# S3 protocol key ID
access_key_id = "minioautumn"
# S3 protocol access key
secret_access_key = "minioautumn"
default_bucket = "revolt-uploads"
[features]
# Feature gate options
webhooks_enabled = false
# Enable push notifications for mass pings (everyone, online, roles)
# When false this will still ping in-client but will not send notifications from pushd
mass_mentions_send_notifications = true
# Can role/everyone pings be used at all
mass_mentions_enabled = true
[features.limits]
[features.limits.global]
group_size = 100
message_embeds = 5
message_replies = 5
message_reactions = 20
server_emoji = 100
server_roles = 200
server_channels = 200
# How many hours since creation a user is considered new
new_user_hours = 72
# Maximum permissible body size in bytes for uploads
# (should be greater than any one file upload limit)
body_limit_size = 20_000_000
# If any userids are entered here, only those users will be able to create servers.
# Leave empty to allow all users to create servers
restrict_server_creation = []
[features.limits.new_user]
# Limits imposed on new users
# Number of outgoing friend requests permitted at any time
outgoing_friend_requests = 5
# Maximum number of owned bots
bots = 2
# Message content length
message_length = 2000
# Number of attachments that can be included
message_attachments = 5
# Maximum number of servers the user can create/join
servers = 50
# Maximum audio frequency (Hz) in voice calls
voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Maximum resolution (width, height) of video streams in voice calls
video_resolution = [1080, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5]
[features.limits.new_user.file_upload_size_limit]
# Maximum file size limits (in bytes)
attachments = 20_000_000
avatars = 4_000_000
backgrounds = 6_000_000
icons = 2_500_000
banners = 6_000_000
emojis = 500_000
[features.limits.default]
# Limits imposed on users by default
# Number of outgoing friend requests permitted at any time
outgoing_friend_requests = 10
# Maximum number of owned bots
bots = 5
# Message content length
message_length = 2000
# Number of attachments that can be included
message_attachments = 5
# Maximum number of servers the user can create/join
servers = 100
# Maximum audio frequency (Hz) in voice calls
voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Maximum resolution (width, height) of video streams in voice calls
video_resolution = [1280, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5]
[features.limits.default.file_upload_size_limit]
# Maximum file size limits (in bytes)
attachments = 20_000_000
avatars = 4_000_000
backgrounds = 6_000_000
icons = 2_500_000
banners = 6_000_000
emojis = 500_000
[features.advanced]
# The max amount of messages the rabbitmq provider/db mention adder job will delay for before forcing handling of a channel.
# default: 5
process_message_delay_limit = 5
[features.legal_links]
# URLs for legal documents
terms_of_service = ""
privacy_policy = ""
guidelines = ""
[sentry]
# Configuration for Sentry error reporting
api = ""
events = ""
voice_ingress = ""
files = ""
proxy = ""
pushd = ""
crond = ""
gifbox = ""

View File

@@ -1,593 +0,0 @@
#[cfg(feature = "test")]
use std::sync::OnceLock;
use std::{collections::HashMap, path::Path, sync::LazyLock};
use cached::proc_macro::cached;
use config::{Config, Environment, File, FileFormat};
use futures_locks::RwLock;
use serde::Deserialize;
#[cfg(feature = "sentry")]
pub use sentry::{capture_error, capture_message, Level};
#[cfg(feature = "anyhow")]
pub use sentry_anyhow::capture_anyhow;
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! report_error {
( $expr: expr, $error: ident $( $tt:tt )? ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!($error))
};
}
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! capture_internal_error {
( $expr: expr ) => {
$crate::capture_message(
&format!("{:?} ({}:{}:{})", $expr, file!(), line!(), column!()),
$crate::Level::Error,
);
};
}
#[cfg(all(feature = "report-macros", feature = "sentry"))]
#[macro_export]
macro_rules! report_internal_error {
( $expr: expr ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!(InternalError))
};
}
/// Paths to search for configuration
static CONFIG_SEARCH_PATHS: [&str; 3] = [
// current working directory
"Revolt.toml",
// current working directory - overrides file
"Revolt.overrides.toml",
// root directory, for Docker containers
"/Revolt.toml",
];
/// Path to search for test overrides
static TEST_OVERRIDE_PATH: &str = "Revolt.test-overrides.toml";
/// Configuration builder
static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
RwLock::new({
let mut builder = Config::builder().add_source(File::from_str(
include_str!("../Revolt.toml"),
FileFormat::Toml,
));
let cwd = std::env::current_dir().unwrap();
let mut cwd: Option<&Path> = Some(&cwd);
while let Some(path) = cwd {
for config_path in CONFIG_SEARCH_PATHS {
let config_path = path.join(config_path);
if config_path.exists() {
builder = builder
.add_source(File::new(config_path.to_str().unwrap(), FileFormat::Toml));
}
}
cwd = path.parent();
}
if std::env::var("TEST_DB").is_ok() {
builder = builder.add_source(File::from_str(
include_str!("../Revolt.test.toml"),
FileFormat::Toml,
));
// recursively search upwards for an overrides file (if there is one)
if let Ok(cwd) = std::env::current_dir() {
let mut path = Some(cwd.as_path());
while let Some(current_path) = path {
let target_path = current_path.join(TEST_OVERRIDE_PATH);
if target_path.exists() {
builder = builder
.add_source(File::new(target_path.to_str().unwrap(), FileFormat::Toml));
}
path = current_path.parent();
}
}
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap()
})
});
#[derive(Deserialize, Debug, Clone)]
pub struct Database {
pub mongodb: String,
pub redis: String,
pub redis_pubsub: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Rabbit {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Hosts {
pub app: String,
pub api: String,
pub events: String,
pub autumn: String,
pub january: String,
pub livekit: HashMap<String, String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiRegistration {
pub invite_only: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSmtp {
pub host: String,
pub username: String,
pub password: String,
pub from_address: String,
pub reply_to: Option<String>,
pub port: Option<i32>,
pub use_tls: Option<bool>,
pub use_starttls: Option<bool>,
pub expiry: EmailExpiry,
}
/// Email expiration config
#[derive(Deserialize, Debug, Clone)]
pub struct EmailExpiry {
/// How long email verification codes should last for (in seconds)
pub expire_verification: i64,
/// How long password reset codes should last for (in seconds)
pub expire_password_reset: i64,
/// How long account deletion codes should last for (in seconds)
pub expire_account_deletion: i64,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushVapid {
pub queue: String,
pub private_key: String,
pub public_key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushFcm {
pub queue: String,
pub key_type: String,
pub project_id: String,
pub private_key_id: String,
pub private_key: String,
pub client_email: String,
pub client_id: String,
pub auth_uri: String,
pub token_uri: String,
pub auth_provider_x509_cert_url: String,
pub client_x509_cert_url: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushApn {
pub queue: String,
pub sandbox: bool,
pub pkcs8: String,
pub key_id: String,
pub team_id: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityCaptcha {
pub hcaptcha_key: String,
pub hcaptcha_sitekey: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityShield {
pub host: String,
pub key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurity {
pub shield: ApiSecurityShield,
pub voso_legacy_token: String,
pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool,
pub easypwned: String,
pub tenor_key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiWorkers {
pub max_concurrent_connections: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiLiveKit {
pub call_ring_duration: usize,
pub nodes: HashMap<String, LiveKitNode>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LiveKitNode {
pub url: String,
pub lat: f64,
pub lon: f64,
pub key: String,
pub secret: String,
// whether to hide the node in the nodes list
#[serde(default)]
pub private: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Api {
pub registration: ApiRegistration,
pub smtp: ApiSmtp,
pub security: ApiSecurity,
pub workers: ApiWorkers,
pub livekit: ApiLiveKit,
pub users: ApiUsers,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Pushd {
pub production: bool,
pub exchange: String,
pub mass_mention_chunk_size: usize,
pub render_cache_time: usize,
// Queues
pub message_queue: String,
pub mass_mention_queue: String,
pub dm_call_queue: String,
pub fr_accepted_queue: String,
pub fr_received_queue: String,
pub generic_queue: String,
pub ack_queue: String,
pub vapid: PushVapid,
pub fcm: PushFcm,
pub apn: PushApn,
}
impl Pushd {
fn get_routing_key(&self, key: String) -> String {
match self.production {
true => key + "-prd",
false => key + "-tst",
}
}
pub fn get_ack_routing_key(&self) -> String {
self.get_routing_key(self.ack_queue.clone())
}
pub fn get_message_routing_key(&self) -> String {
self.get_routing_key(self.message_queue.clone())
}
pub fn get_mass_mention_routing_key(&self) -> String {
self.get_routing_key(self.mass_mention_queue.clone())
}
pub fn get_dm_call_routing_key(&self) -> String {
self.get_routing_key(self.dm_call_queue.clone())
}
pub fn get_fr_accepted_routing_key(&self) -> String {
self.get_routing_key(self.fr_accepted_queue.clone())
}
pub fn get_fr_received_routing_key(&self) -> String {
self.get_routing_key(self.fr_received_queue.clone())
}
pub fn get_generic_routing_key(&self) -> String {
self.get_routing_key(self.generic_queue.clone())
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit {
pub min_file_size: usize,
pub min_resolution: [usize; 2],
pub max_mega_pixels: usize,
pub max_pixel_side: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesS3 {
pub endpoint: String,
pub path_style_buckets: bool,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub default_bucket: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Files {
pub encryption_key: String,
pub webp_quality: f32,
pub blocked_mime_types: Vec<String>,
pub clamd_host: String,
pub scan_mime_types: Vec<String>,
pub limit: FilesLimit,
pub preview: HashMap<String, [usize; 2]>,
pub s3: FilesS3,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GlobalLimits {
pub group_size: usize,
pub message_embeds: usize,
pub message_replies: usize,
pub message_reactions: usize,
pub server_emoji: usize,
pub server_roles: usize,
pub server_channels: usize,
pub new_user_hours: usize,
pub body_limit_size: usize,
pub restrict_server_creation: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits {
pub outgoing_friend_requests: usize,
pub bots: usize,
pub message_length: usize,
pub message_attachments: usize,
pub servers: usize,
pub voice_quality: u32,
pub video: bool,
pub video_resolution: [u32; 2],
pub video_aspect_ratio: [f32; 2],
pub file_upload_size_limit: HashMap<String, usize>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimitsCollection {
pub global: GlobalLimits,
pub new_user: FeaturesLimits,
pub default: FeaturesLimits,
#[serde(flatten)]
pub roles: HashMap<String, FeaturesLimits>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced {
#[serde(default)]
pub process_message_delay_limit: u16,
}
impl Default for FeaturesAdvanced {
fn default() -> Self {
Self {
process_message_delay_limit: 5,
}
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct Features {
pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool,
#[serde(default)]
pub advanced: FeaturesAdvanced,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Sentry {
pub api: String,
pub events: String,
pub voice_ingress: String,
pub files: String,
pub proxy: String,
pub pushd: String,
pub crond: String,
pub gifbox: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Settings {
pub database: Database,
pub rabbit: Rabbit,
pub hosts: Hosts,
pub api: Api,
pub pushd: Pushd,
pub january: January,
pub files: Files,
pub features: Features,
pub sentry: Sentry,
pub production: bool,
pub environment: String,
pub disable_events_dont_use: bool,
}
impl Settings {
pub fn preflight_checks(&self) {
if self.api.smtp.host.is_empty() {
log::warn!("No SMTP settings specified! Remember to configure email.");
}
if self.api.security.captcha.hcaptcha_key.is_empty() {
log::warn!("No Captcha key specified! Remember to add hCaptcha key.");
}
}
}
pub async fn init() {
println!(
":: Revolt Configuration ::\n\x1b[32m{:?}\x1b[0m",
config().await
);
}
pub async fn read() -> Config {
CONFIG_BUILDER.read().await.clone()
}
pub async fn config_no_cache() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library
if std::env::var("REDIS_URI").is_err() {
std::env::set_var("REDIS_URI", config.database.redis.clone());
}
// auto-detect production nodes
if config.hosts.api.contains("https")
&& (config.hosts.api.contains("revolt.chat") || config.hosts.api.contains("stoat.chat"))
{
config.production = true;
}
config
}
#[cached(time = 30)]
pub async fn config() -> Settings {
#[cfg(feature = "test")]
if let Some(overwrites) = CONFIG_OVERWRITES.get() {
return overwrites.clone();
}
config_no_cache().await
}
#[cfg(feature = "test")]
static CONFIG_OVERWRITES: OnceLock<Settings> = OnceLock::new();
/// Modify the config values for a test, this can only be called once
///
/// This will also fail if two or more tests are running in the same process and both try to modify the config,
/// This could happen if tests where run under `cargo test` instead of `nextest`.
#[cfg(feature = "test")]
pub async fn overwrite_config(f: impl FnOnce(&mut Settings)) {
let mut config = config_no_cache().await;
f(&mut config);
CONFIG_OVERWRITES.set(config).expect(
"Cannot overwrite config multiple times, make sure you are running tests through nextest.",
);
}
/// Configure logging and common Rust variables
#[cfg(feature = "sentry")]
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {
if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "info");
}
if std::env::var("ROCKET_ADDRESS").is_err() {
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
}
pretty_env_logger::init();
log::info!("Starting {release}");
if dsn.is_empty() {
None
} else {
Some(sentry::init((
dsn,
sentry::ClientOptions {
release: Some(release.into()),
..Default::default()
},
)))
}
}
#[cfg(feature = "sentry")]
#[macro_export]
macro_rules! configure {
($application: ident) => {
let config = $crate::config().await;
let _sentry = $crate::setup_logging(
concat!(env!("CARGO_PKG_NAME"), "@", env!("CARGO_PKG_VERSION")),
config.sentry.$application,
)
.await;
};
}
#[cfg(feature = "test")]
#[cfg(test)]
mod tests {
use crate::init;
#[async_std::test]
async fn it_works() {
init().await;
}
}

View File

@@ -1,113 +0,0 @@
[package]
name = "revolt-database"
version = "0.13.7"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Database Implementation"
repository = "https://github.com/stoatchat/stoatchat"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
# Databases
mongodb = ["dep:mongodb", "bson"]
# ... Other
tasks = ["isahc", "linkify", "url-escape"]
async-std-runtime = ["async-std"]
rocket-impl = [
"rocket",
"schemars",
"revolt_okapi",
"revolt_rocket_okapi",
]
axum-impl = ["axum", "revolt-result/axum", "utoipa"]
redis-is-patched = ["revolt-presence/redis-is-patched"]
voice = ["livekit-api", "livekit-protocol", "livekit-runtime"]
# Default Features
default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies]
# Core
revolt-config = { workspace = true, features = ["report-macros"] }
revolt-result = { workspace = true }
revolt-models = { workspace = true, features = ["validator"] }
revolt-presence = { workspace = true }
revolt-permissions = { workspace = true, features = ["serde", "bson"] }
revolt-parser = { workspace = true }
revolt-coalesced = { workspace = true }
# Utility
log = { workspace = true }
lru = { workspace = true }
rand = { workspace = true }
ulid = { workspace = true }
nanoid = { workspace = true }
base64 = { workspace = true }
once_cell = { workspace = true }
indexmap = { workspace = true }
decancer = { workspace = true }
deadqueue = { workspace = true }
linkify = { workspace = true, optional = true }
url-escape = { workspace = true, optional = true }
validator = { workspace = true, features = ["derive"] }
isahc = { workspace = true, features = ["json"], optional = true }
base32 = { workspace = true }
sha1 = { workspace = true }
# Serialisation
serde_json = { workspace = true }
revolt_optional_struct = { workspace = true }
serde = { workspace = true }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
# Events
redis-kiss = { workspace = true }
# Database
bson = { workspace = true, optional = true }
mongodb = { workspace = true, optional = true }
# Database Migration
unicode-segmentation = { workspace = true }
regex = { workspace = true }
# Async Language Features
futures = { workspace = true }
async-lock = { workspace = true }
async-trait = { workspace = true }
async-recursion = { workspace = true }
# Async
async-std = { workspace = true, features = ["attributes"], optional = true }
# Axum Impl
axum = { workspace = true, optional = true }
utoipa = { workspace = true, features = ["axum_extras"], optional = true }
# Rocket Impl
schemars = { workspace = true, optional = true }
rocket = { workspace = true, features = ["json"], optional = true }
revolt_okapi = { workspace = true, optional = true }
revolt_rocket_okapi = { workspace = true, optional = true }
# RabbitMQ
lapin = { workspace = true, features = ["tokio"] }
# Voice
livekit-api = { workspace = true, features = ["rustls-tls-native-roots"], optional = true }
livekit-protocol = { workspace = true, optional = true }
livekit-runtime = { workspace = true, features = ["tokio"], optional = true }
# Security
totp-lite = { workspace = true }
rust-argon2 = { workspace = true }
# Email
lettre = { workspace = true }
handlebars = { workspace = true }
# Web Requests
reqwest = { workspace = true, features = ["json", "form"] }

File diff suppressed because it is too large Load Diff

View File

@@ -1,555 +0,0 @@
this is an assorted list of known disposable email providers
#region nobody will ever have an email @example.com so we can safely block it
----
example.com
#region list provided by michenriksen at https://gist.github.com/michenriksen/8710649
----
0815.ru
0wnd.net
0wnd.org
10minutemail.co.za
10minutemail.com
123-m.com
1fsdfdsfsdf.tk
1pad.de
20minutemail.com
21cn.com
2fdgdfgdfgdf.tk
2prong.com
30minutemail.com
33mail.com
3trtretgfrfe.tk
4gfdsgfdgfd.tk
4warding.com
5ghgfhfghfgh.tk
6hjgjhgkilkj.tk
6paq.com
7tags.com
9ox.net
a-bc.net
agedmail.com
ama-trade.de
amilegit.com
amiri.net
amiriindustries.com
anonmails.de
anonymbox.com
antichef.com
antichef.net
antireg.ru
antispam.de
antispammail.de
armyspy.com
artman-conception.com
azmeil.tk
baxomale.ht.cx
beefmilk.com
bigstring.com
binkmail.com
bio-muesli.net
bobmail.info
bodhi.lawlita.com
bofthew.com
bootybay.de
boun.cr
bouncr.com
breakthru.com
brefmail.com
bsnow.net
bspamfree.org
bugmenot.com
bund.us
burstmail.info
buymoreplays.com
byom.de
c2.hu
casualdx.com
cek.pm
centermail.com
centermail.net
chammy.info
childsavetrust.org
chogmail.com
choicemail1.com
clixser.com
cmail.net
cmail.org
coldemail.info
cool.fr.nf
courriel.fr.nf
courrieltemporaire.com
crapmail.org
cust.in
cuvox.de
d3p.dk
dacoolest.com
dandikmail.com
dayrep.com
dcemail.com
deadaddress.com
deadspam.com
delikkt.de
despam.it
despammed.com
devnullmail.com
dfgh.net
digitalsanctuary.com
dingbone.com
disposableaddress.com
disposableemailaddresses.com
disposableinbox.com
dispose.it
dispostable.com
dodgeit.com
dodgit.com
donemail.ru
dontreg.com
dontsendmespam.de
drdrb.net
dump-email.info
dumpandjunk.com
dumpyemail.com
e-mail.com
e-mail.org
e4ward.com
easytrashmail.com
einmalmail.de
einrot.com
eintagsmail.de
emailgo.de
emailias.com
emaillime.com
emailsensei.com
emailtemporanea.com
emailtemporanea.net
emailtemporar.ro
emailtemporario.com.br
emailthe.net
emailtmp.com
emailwarden.com
emailx.at.hm
emailxfer.com
emeil.in
emeil.ir
emz.net
ero-tube.org
evopo.com
explodemail.com
eyepaste.com
fakeinbox.com
fakeinformation.com
fansworldwide.de
fantasymail.de
fightallspam.com
filzmail.com
fivemail.de
fleckens.hu
frapmail.com
friendlymail.co.uk
fuckingduh.com
fudgerub.com
fyii.de
garliclife.com
gehensiemirnichtaufdensack.de
get2mail.fr
getairmail.com
getmails.eu
getonemail.com
giantmail.de
girlsundertheinfluence.com
gishpuppy.com
gmial.com
goemailgo.com
gotmail.net
gotmail.org
gotti.otherinbox.com
great-host.in
greensloth.com
grr.la
gsrv.co.uk
guerillamail.biz
guerillamail.com
guerrillamail.biz
guerrillamail.com
guerrillamail.de
guerrillamail.info
guerrillamail.net
guerrillamail.org
guerrillamailblock.com
gustr.com
harakirimail.com
hat-geld.de
hatespam.org
herp.in
hidemail.de
hidzz.com
hmamail.com
hopemail.biz
ieh-mail.de
ikbenspamvrij.nl
imails.info
inbax.tk
inbox.si
inboxalias.com
inboxclean.com
inboxclean.org
instant-mail.de
ip6.li
irish2me.com
iwi.net
jetable.com
jetable.fr.nf
jetable.net
jetable.org
jnxjn.com
jourrapide.com
jsrsolutions.com
kasmail.com
kaspop.com
killmail.com
killmail.net
klassmaster.com
klzlk.com
koszmail.pl
kurzepost.de
lawlita.com
letthemeatspam.com
lhsdv.com
lifebyfood.com
link2mail.net
litedrop.com
lol.ovpn.to
lolfreak.net
lookugly.com
lortemail.dk
lr78.com
lroid.com
lukop.dk
m21.cc
mail-filter.com
mail-temporaire.fr
mail.mezimages.net
mail1a.de
mail21.cc
mail2rss.org
mail333.com
mailbidon.com
mailbiz.biz
mailblocks.com
mailbucket.org
mailcat.biz
mailcatch.com
mailde.de
mailde.info
maildrop.cc
maileimer.de
mailexpire.com
mailfa.tk
mailforspam.com
mailfreeonline.com
mailguard.me
mailin8r.com
mailinater.com
mailinator.com
mailinator.net
mailinator.org
mailinator2.com
mailincubator.com
mailismagic.com
mailme.lv
mailme24.com
mailmetrash.com
mailmoat.com
mailms.com
mailnesia.com
mailnull.com
mailorg.org
mailpick.biz
mailrock.biz
mailscrap.com
mailshell.com
mailsiphon.com
mailtemp.info
mailtome.de
mailtothis.com
mailtrash.net
mailtv.net
mailtv.tv
mailzilla.com
makemetheking.com
manybrain.com
mbx.cc
mega.zik.dj
meinspamschutz.de
meltmail.com
messagebeamer.de
mezimages.net
ministry-of-silly-walks.de
mintemail.com
misterpinball.de
moncourrier.fr.nf
monemail.fr.nf
monmail.fr.nf
monumentmail.com
mt2009.com
mt2014.com
mycleaninbox.net
mymail-in.net
mypacks.net
mypartyclip.de
myphantomemail.com
mysamp.de
mytempemail.com
mytempmail.com
mytrashmail.com
nabuma.com
neomailbox.com
nepwk.com
nervmich.net
nervtmich.net
netmails.com
netmails.net
neverbox.com
nice-4u.com
nincsmail.hu
nnh.com
no-spam.ws
noblepioneer.com
nomail.pw
nomail.xl.cx
nomail2me.com
nomorespamemails.com
nospam.ze.tc
nospam4.us
nospamfor.us
nospammail.net
notmailinator.com
nowhere.org
nowmymail.com
nurfuerspam.de
nus.edu.sg
objectmail.com
obobbo.com
odnorazovoe.ru
oneoffemail.com
onewaymail.com
onlatedotcom.info
online.ms
ordinaryamerican.net
otherinbox.com
ovpn.to
owlpic.com
pancakemail.com
pcusers.otherinbox.com
pjjkp.com
plexolan.de
poczta.onet.pl
politikerclub.de
poofy.org
pookmail.com
privacy.net
privatdemail.net
proxymail.eu
prtnx.com
putthisinyourspamdatabase.com
putthisinyourspamdatabase.com
quickinbox.com
rcpt.at
reallymymail.com
realtyalerts.ca
recode.me
recursor.net
reliable-mail.com
rhyta.com
rmqkr.net
royal.net
rtrtr.com
s0ny.net
safersignup.de
safetymail.info
safetypost.de
saynotospams.com
schafmail.de
schrott-email.de
secretemail.de
secure-mail.biz
senseless-entertainment.com
services391.com
sharklasers.com
shieldemail.com
shiftmail.com
shitmail.me
shitware.nl
shmeriously.com
shortmail.net
sinnlos-mail.de
slapsfromlastnight.com
slaskpost.se
smashmail.de
smellfear.com
snakemail.com
sneakemail.com
sneakmail.de
snkmail.com
sofimail.com
solvemail.info
sogetthis.com
soodonims.com
spam4.me
spamail.de
spamarrest.com
spambob.net
spambog.ru
spambox.us
spamcannon.com
spamcannon.net
spamcon.org
spamcorptastic.com
spamcowboy.com
spamcowboy.net
spamcowboy.org
spamday.com
spamex.com
spamfree.eu
spamfree24.com
spamfree24.de
spamfree24.org
spamgoes.in
spamgourmet.com
spamgourmet.net
spamgourmet.org
spamherelots.com
spamherelots.com
spamhereplease.com
spamhereplease.com
spamhole.com
spamify.com
spaml.de
spammotel.com
spamobox.com
spamslicer.com
spamspot.com
spamthis.co.uk
spamtroll.net
speed.1s.fr
spoofmail.de
stuffmail.de
super-auswahl.de
supergreatmail.com
supermailer.jp
superrito.com
superstachel.de
suremail.info
talkinator.com
teewars.org
teleworm.com
teleworm.us
temp-mail.org
temp-mail.ru
tempe-mail.com
tempemail.co.za
tempemail.com
tempemail.net
tempemail.net
tempinbox.co.uk
tempinbox.com
tempmail.eu
tempmaildemo.com
tempmailer.com
tempmailer.de
tempomail.fr
temporaryemail.net
temporaryforwarding.com
temporaryinbox.com
temporarymailaddress.com
tempthe.net
thankyou2010.com
thc.st
thelimestones.com
thisisnotmyrealemail.com
thismail.net
throwawayemailaddress.com
tilien.com
tittbit.in
tizi.com
tmailinator.com
toomail.biz
topranklist.de
tradermail.info
trash-mail.at
trash-mail.com
trash-mail.de
trash2009.com
trashdevil.com
trashemail.de
trashmail.at
trashmail.com
trashmail.de
trashmail.me
trashmail.net
trashmail.org
trashymail.com
trialmail.de
trillianpro.com
twinmail.de
tyldd.com
uggsrock.com
umail.net
uroid.com
us.af
venompen.com
veryrealemail.com
viditag.com
viralplays.com
vpn.st
vsimcard.com
vubby.com
wasteland.rfc822.org
webemail.me
weg-werf-email.de
wegwerf-emails.de
wegwerfadresse.de
wegwerfemail.com
wegwerfemail.de
wegwerfmail.de
wegwerfmail.info
wegwerfmail.net
wegwerfmail.org
wh4f.org
whyspam.me
willhackforfood.biz
willselfdestruct.com
winemaven.info
wronghead.com
www.e4ward.com
www.mailinator.com
wwwnew.eu
x.ip6.li
xagloo.com
xemaps.com
xents.com
xmaily.com
xoxy.net
yep.it
yogamaven.com
yopmail.com
yopmail.fr
yopmail.net
yourdomain.com
yuurok.com
z1p.biz
za.com
zehnminuten.de
zehnminutenmail.de
zippymail.info
zoemail.net
zomg.info
#region public emails provided by mail.tm
----
trythe.net
leadwizzer.com
metalunits.com
scpulse.com

View File

@@ -1,34 +0,0 @@
[
{
"_object_type": "User",
"_id": "__ID:0__",
"username": "Owner",
"last_acknowledged_policy_change": "2025-06-07T04:04:48+0000",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:1__",
"username": "Member",
"last_acknowledged_policy_change": "2025-06-07T04:04:48+0000",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:2__",
"username": "Member",
"last_acknowledged_policy_change": "2025-06-07T04:04:48+0000",
"discriminator": "0002"
},
{
"_object_type": "Channel",
"_id": "__ID:3__",
"channel_type": "Group",
"name": "My Group",
"owner": "__ID:0__",
"recipients": [
"__ID:0__",
"__ID:1__"
]
}
]

View File

@@ -1,100 +0,0 @@
[
{
"_object_type": "User",
"_id": "__ID:0__",
"username": "Owner",
"last_acknowledged_policy_change": "2025-06-07T04:04:48+0000",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:1__",
"username": "Moderator",
"last_acknowledged_policy_change": "2025-06-07T04:04:48+0000",
"discriminator": "0001"
},
{
"_object_type": "User",
"_id": "__ID:2__",
"username": "User",
"last_acknowledged_policy_change": "2025-06-07T04:04:48+0000",
"discriminator": "0001"
},
{
"_object_type": "Channel",
"_id": "__ID:3__",
"channel_type": "TextChannel",
"name": "General",
"server": "__ID:4__",
"default_permissions": {
"a": 0,
"d": 1048576
},
"role_permissions": {
"__ID:5__": {
"a": 1048576,
"d": 0
}
}
},
{
"_object_type": "Server",
"_id": "__ID:4__",
"owner": "__ID:0__",
"name": "Server",
"channels": [
"__ID:3__"
],
"roles": {
"__ID:5__": {
"_id": "__ID:5__",
"name": "Moderator",
"permissions": {
"a": 545270208,
"d": 0
},
"rank": 1
},
"__ID:6__": {
"_id": "__ID:6__",
"name": "Owner",
"permissions": {
"a": 0,
"d": 0
},
"rank": 0
}
},
"default_permissions": 4000322560
},
{
"_object_type": "ServerMember",
"_id": {
"user": "__ID:0__",
"server": "__ID:4__"
},
"roles": [
"__ID:6__"
],
"joined_at": 1698318340195
},
{
"_object_type": "ServerMember",
"_id": {
"user": "__ID:1__",
"server": "__ID:4__"
},
"roles": [
"__ID:5__"
],
"joined_at": 1698318340195
},
{
"_object_type": "ServerMember",
"_id": {
"user": "__ID:2__",
"server": "__ID:4__"
},
"joined_at": 1698318340195
}
]

View File

@@ -1,382 +0,0 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::events::rabbit::*;
use crate::User;
use lapin::{
options::BasicPublishOptions,
protocol::basic::AMQPProperties,
types::{AMQPValue, FieldTable},
Channel, Connection, ConnectionProperties, Error as AMQPError,
};
use revolt_models::v0::PushNotification;
use revolt_presence::filter_online;
use revolt_result::Result;
use serde_json::to_string;
#[derive(Clone)]
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)]
connection: Arc<Connection>,
}
impl AMQP {
pub async fn new(connection: Arc<Connection>) -> Self {
Self {
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,
}
}
pub async fn new_auto() -> Self {
let config = revolt_config::config().await;
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"),
);
Self::new(connection).await
}
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(
&self,
accepted_request_user: &User,
sent_request_user: &User,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = FRAcceptedPayload {
accepted_user: accepted_request_user.to_owned(),
user: sent_request_user.id.clone(),
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending friend request accept payload on channel {}: {}",
config.pushd.get_fr_accepted_routing_key(),
payload
);
self.friend_request_accepted
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_fr_accepted_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
pub async fn friend_request_received(
&self,
received_request_user: &User,
sent_request_user: &User,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = FRReceivedPayload {
from_user: sent_request_user.to_owned(),
user: received_request_user.id.clone(),
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending friend request received payload on channel {}: {}",
config.pushd.get_fr_received_routing_key(),
payload
);
self.friend_request_received
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_fr_received_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
pub async fn generic_message(
&self,
user: &User,
title: String,
body: String,
icon: Option<String>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = GenericPayload {
title,
body,
icon,
user: user.to_owned(),
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending generic payload on channel {}: {}",
config.pushd.get_generic_routing_key(),
payload
);
self.generic_message
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_generic_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
pub async fn message_sent(
&self,
recipients: Vec<String>,
payload: PushNotification,
) -> Result<(), AMQPError> {
if recipients.is_empty() {
return Ok(());
}
let config = revolt_config::config().await;
let online_ids = filter_online(&recipients).await;
let recipients = (&recipients.into_iter().collect::<HashSet<String>>() - &online_ids)
.into_iter()
.collect::<Vec<String>>();
let payload = MessageSentPayload {
notification: payload,
users: recipients,
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending message payload on channel {}: {}",
config.pushd.get_message_routing_key(),
payload
);
self.message_sent
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_message_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
pub async fn mass_mention_message_sent(
&self,
server_id: String,
payload: Vec<PushNotification>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = MassMessageSentPayload {
notifications: payload,
server_id,
};
let payload = to_string(&payload).unwrap();
let routing_key = config.pushd.get_mass_mention_routing_key();
debug!(
"Sending mass mention payload on channel {}: {}",
routing_key, payload
);
self.mass_mention_message_sent
.basic_publish(
config.pushd.exchange.clone().into(),
routing_key.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
/// # Sends an ack to pushd to update badges on iPhones.
/// Not to be confused with the process_ack function, which handles sending all acks to crond for processing.
pub async fn ack_notification_message(
&self,
user_id: String,
channel_id: String,
message_id: String,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = AckPayload {
user_id: user_id.clone(),
channel_id: channel_id.clone(),
message_id,
};
let payload = to_string(&payload).unwrap();
info!(
"Sending ack payload on channel {}: {}",
config.pushd.ack_queue, payload
);
let mut headers = FieldTable::default();
headers.insert(
"x-deduplication-header".into(),
AMQPValue::LongString(format!("{}-{}", &user_id, &channel_id).into()),
);
self.ack_notification_message
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.ack_queue.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
/// # DM Call Update
/// Used to send an update about a DM call, eg. start or end of a call.
/// Recipients can be used to narrow the scope of recipients, otherwise all recipients will be notified.
/// `ended` refers to the ringing period, not necessarily the call itself.
pub async fn dm_call_updated(
&self,
initiator_id: &str,
channel_id: &str,
started_at: Option<&str>,
ended: bool,
recipients: Option<Vec<String>>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = InternalDmCallPayload {
payload: DmCallPayload {
initiator_id: initiator_id.to_string(),
channel_id: channel_id.to_string(),
started_at: started_at.map(|f| f.to_string()),
ended,
},
recipients,
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending dm call update payload on channel {}: {}",
config.pushd.get_dm_call_routing_key(),
payload
);
self.dm_call_updated
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_dm_call_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.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(())
}
}

View File

@@ -1,2 +0,0 @@
#[allow(clippy::module_inception)]
pub mod amqp;

View File

@@ -1,105 +0,0 @@
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
use rand::Rng;
use revolt_config::config;
#[cfg(feature = "mongodb")]
pub use self::mongodb::*;
pub use self::reference::*;
/// Database information to use to create a client
pub enum DatabaseInfo {
/// Auto-detect the database in use
Auto,
/// Auto-detect the database in use and create an empty testing database
Test(String),
/// Use the mock database
Reference,
/// Connect to MongoDB
#[cfg(feature = "mongodb")]
MongoDb { uri: String, database_name: String },
/// Use existing MongoDB connection
#[cfg(feature = "mongodb")]
MongoDbFromClient(::mongodb::Client, String),
}
/// Database
#[derive(Clone, Debug)]
pub enum Database {
/// Mock database
Reference(ReferenceDb),
/// MongoDB database
#[cfg(feature = "mongodb")]
MongoDb(MongoDb),
}
impl DatabaseInfo {
/// Create a database client from the given database information
#[async_recursion]
pub async fn connect(self) -> Result<Database, String> {
let config = config().await;
match self {
DatabaseInfo::Auto => {
if std::env::var("TEST_DB").is_ok() {
DatabaseInfo::Test(format!(
"revolt_test_{}",
rand::thread_rng().gen_range(1_000_000..10_000_000)
))
.connect()
.await
} else if !config.database.mongodb.is_empty() {
#[cfg(feature = "mongodb")]
return DatabaseInfo::MongoDb {
uri: config.database.mongodb,
database_name: "revolt".to_string(),
}
.connect()
.await;
#[cfg(not(feature = "mongodb"))]
return Err("MongoDB not enabled.".to_string());
} else {
DatabaseInfo::Reference.connect().await
}
}
DatabaseInfo::Test(database_name) => {
match std::env::var("TEST_DB")
.expect("`TEST_DB` environment variable should be set to REFERENCE or MONGODB")
.as_str()
{
"REFERENCE" => DatabaseInfo::Reference.connect().await,
"MONGODB" => {
#[cfg(feature = "mongodb")]
return DatabaseInfo::MongoDb {
uri: config.database.mongodb,
database_name,
}
.connect()
.await;
#[cfg(not(feature = "mongodb"))]
return Err("MongoDB not enabled.".to_string());
}
_ => unreachable!("must specify REFERENCE or MONGODB"),
}
}
DatabaseInfo::Reference => Ok(Database::Reference(Default::default())),
#[cfg(feature = "mongodb")]
DatabaseInfo::MongoDb { uri, database_name } => {
let client = ::mongodb::Client::with_uri_str(uri)
.await
.map_err(|_| "Failed to init db connection.".to_string())?;
Ok(Database::MongoDb(MongoDb(client, database_name)))
}
#[cfg(feature = "mongodb")]
DatabaseInfo::MongoDbFromClient(client, database_name) => {
Ok(Database::MongoDb(MongoDb(client, database_name)))
}
}
}
}

View File

@@ -1,245 +0,0 @@
use std::collections::HashMap;
use std::ops::Deref;
use futures::StreamExt;
use mongodb::bson::{doc, to_document, Document};
use mongodb::error::Result;
use mongodb::options::{FindOneOptions, FindOptions};
use mongodb::results::{DeleteResult, InsertOneResult, UpdateResult};
use serde::de::DeserializeOwned;
use serde::Serialize;
database_derived!(
/// MongoDB implementation
#[derive(Debug)]
pub struct MongoDb(pub ::mongodb::Client, pub String);
);
impl Deref for MongoDb {
type Target = mongodb::Client;
fn deref(&self) -> &Self::Target {
&self.0
}
}
#[allow(dead_code)]
impl MongoDb {
/// Get the Revolt database
pub fn db(&self) -> mongodb::Database {
self.database(&self.1)
}
/// Get a collection by its name
pub fn col<T: Send + Sync>(&self, collection: &str) -> mongodb::Collection<T> {
self.db().collection(collection)
}
/// Insert one document into a collection
pub async fn insert_one<T: Serialize + Send + Sync>(
&self,
collection: &'static str,
document: T,
) -> Result<InsertOneResult> {
self.col::<T>(collection).insert_one(document).await
}
/// Count documents by projection
pub async fn count_documents(
&self,
collection: &'static str,
projection: Document,
) -> Result<u64> {
self.col::<Document>(collection)
.count_documents(projection)
.await
}
/// Find multiple documents in a collection with options
pub async fn find_with_options<O, T: DeserializeOwned + Unpin + Send + Sync>(
&self,
collection: &'static str,
projection: Document,
options: O,
) -> Result<Vec<T>>
where
O: Into<Option<FindOptions>>,
{
Ok(self
.col::<T>(collection)
.find(projection)
.with_options(options)
.await?
.filter_map(|s| async {
if cfg!(debug_assertions) {
// Hard fail on invalid documents
Some(s.unwrap())
} else {
s.ok()
}
})
.collect::<Vec<T>>()
.await)
}
/// Find multiple documents in a collection
pub async fn find<T: DeserializeOwned + Unpin + Send + Sync>(
&self,
collection: &'static str,
projection: Document,
) -> Result<Vec<T>> {
self.find_with_options(collection, projection, None).await
}
/// Find one document with options
pub async fn find_one_with_options<O, T: DeserializeOwned + Unpin + Send + Sync>(
&self,
collection: &'static str,
projection: Document,
options: O,
) -> Result<Option<T>>
where
O: Into<Option<FindOneOptions>>,
{
self.col::<T>(collection)
.find_one(projection)
.with_options(options)
.await
}
/// Find one document
pub async fn find_one<T: DeserializeOwned + Unpin + Send + Sync>(
&self,
collection: &'static str,
projection: Document,
) -> Result<Option<T>> {
self.find_one_with_options(collection, projection, None)
.await
}
/// Find one document by its ID
pub async fn find_one_by_id<T: DeserializeOwned + Unpin + Send + Sync>(
&self,
collection: &'static str,
id: &str,
) -> Result<Option<T>> {
self.find_one(
collection,
doc! {
"_id": id
},
)
.await
}
/// Update one document given a projection, partial document, and list of paths to unset
pub async fn update_one<P, T: Serialize>(
&self,
collection: &'static str,
projection: Document,
partial: T,
remove: Vec<&dyn IntoDocumentPath>,
prefix: P,
) -> Result<UpdateResult>
where
P: Into<Option<String>>,
{
let prefix = prefix.into();
let mut unset = doc! {};
for field in remove {
if let Some(path) = field.as_path() {
if let Some(prefix) = &prefix {
unset.insert(prefix.to_owned() + path, 1_i32);
} else {
unset.insert(path, 1_i32);
}
}
}
let query = doc! {
"$unset": unset,
"$set": if let Some(prefix) = &prefix {
to_document(&prefix_keys(&partial, prefix))
} else {
to_document(&partial)
}?
};
self.col::<Document>(collection)
.update_one(projection, query)
.await
}
/// Update one document given an ID, partial document, and list of paths to unset
pub async fn update_one_by_id<P, T: Serialize>(
&self,
collection: &'static str,
id: &str,
partial: T,
remove: Vec<&dyn IntoDocumentPath>,
prefix: P,
) -> Result<UpdateResult>
where
P: Into<Option<String>>,
{
self.update_one(
collection,
doc! {
"_id": id
},
partial,
remove,
prefix,
)
.await
}
/// Delete one document by the given projection
pub async fn delete_one(
&self,
collection: &'static str,
projection: Document,
) -> Result<DeleteResult> {
self.col::<Document>(collection)
.delete_one(projection)
.await
}
/// Delete one document by the given ID
pub async fn delete_one_by_id(
&self,
collection: &'static str,
id: &str,
) -> Result<DeleteResult> {
self.delete_one(
collection,
doc! {
"_id": id
},
)
.await
}
}
/// Just a string ID struct
#[derive(Deserialize)]
pub struct DocumentId {
#[serde(rename = "_id")]
pub id: String,
}
pub trait IntoDocumentPath: Send + Sync {
/// Create JSON key path
fn as_path(&self) -> Option<&'static str>;
}
/// Prefix keys on an arbitrary object
pub fn prefix_keys<T: Serialize>(t: &T, prefix: &str) -> HashMap<String, serde_json::Value> {
let v: String = serde_json::to_string(t).unwrap();
let v: HashMap<String, serde_json::Value> = serde_json::from_str(&v).unwrap();
v.into_iter()
.filter(|(_k, v)| !v.is_null())
.map(|(k, v)| (format!("{}{}", prefix.to_owned(), k), v))
.collect()
}

View File

@@ -1,38 +0,0 @@
use std::{collections::HashMap, sync::Arc};
use futures::lock::Mutex;
use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot,
User, UserSettings, Webhook, Account, AccountInvite, Session, MFATicket
};
database_derived!(
/// Reference implementation
#[derive(Default, Debug)]
pub struct ReferenceDb {
pub bots: Arc<Mutex<HashMap<String, Bot>>>,
pub channels: Arc<Mutex<HashMap<String, Channel>>>,
pub channel_invites: Arc<Mutex<HashMap<String, Invite>>>,
pub channel_unreads: Arc<Mutex<HashMap<ChannelCompositeKey, ChannelUnread>>>,
pub channel_webhooks: Arc<Mutex<HashMap<String, Webhook>>>,
pub emojis: Arc<Mutex<HashMap<String, Emoji>>>,
pub file_hashes: Arc<Mutex<HashMap<String, FileHash>>>,
pub files: Arc<Mutex<HashMap<String, File>>>,
pub messages: Arc<Mutex<HashMap<String, Message>>>,
pub policy_changes: Arc<Mutex<HashMap<String, PolicyChange>>>,
pub ratelimit_events: Arc<Mutex<HashMap<String, RatelimitEvent>>>,
pub user_settings: Arc<Mutex<HashMap<String, UserSettings>>>,
pub users: Arc<Mutex<HashMap<String, User>>>,
pub server_bans: Arc<Mutex<HashMap<MemberCompositeKey, ServerBan>>>,
pub server_members: Arc<Mutex<HashMap<MemberCompositeKey, Member>>>,
pub servers: Arc<Mutex<HashMap<String, Server>>>,
pub safety_reports: Arc<Mutex<HashMap<String, Report>>>,
pub safety_snapshots: Arc<Mutex<HashMap<String, Snapshot>>>,
pub accounts: Arc<Mutex<HashMap<String, Account>>>,
pub account_invites: Arc<Mutex<HashMap<String, AccountInvite>>>,
pub sessions: Arc<Mutex<HashMap<String, Session>>>,
pub tickets: Arc<Mutex<HashMap<String, MFATicket>>>,
}
);

View File

@@ -1,417 +0,0 @@
use revolt_result::Error;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, ChannelSlowmode, ChannelUnread, ChannelVoiceState, Emoji,
FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser,
FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji,
PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState,
PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings,
UserVoiceState, Webhook,
};
use crate::{Account, Database, Session};
/// Ping Packet
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)]
pub enum Ping {
Binary(Vec<u8>),
Number(usize),
}
/// Fields provided in Ready payload
#[derive(PartialEq, Debug, Clone, Deserialize)]
pub struct ReadyPayloadFields {
pub users: bool,
pub servers: bool,
pub channels: bool,
pub members: bool,
pub emojis: bool,
pub voice_states: bool,
pub user_settings: Vec<String>,
pub channel_unreads: bool,
pub policy_changes: bool,
}
impl Default for ReadyPayloadFields {
fn default() -> Self {
Self {
users: true,
servers: true,
channels: true,
members: true,
emojis: true,
voice_states: true,
user_settings: Vec::new(),
channel_unreads: false,
policy_changes: true,
}
}
}
/// Protocol Events
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "type")]
pub enum EventV1 {
/// Multiple events
Bulk {
v: Vec<EventV1>,
},
/// Error event
Error {
data: Error,
},
/// Successfully authenticated
Authenticated,
/// Logged out
Logout,
/// Basic data to cache
Ready {
#[serde(skip_serializing_if = "Option::is_none")]
users: Option<Vec<User>>,
#[serde(skip_serializing_if = "Option::is_none")]
servers: Option<Vec<Server>>,
#[serde(skip_serializing_if = "Option::is_none")]
channels: Option<Vec<Channel>>,
#[serde(skip_serializing_if = "Option::is_none")]
members: Option<Vec<Member>>,
#[serde(skip_serializing_if = "Option::is_none")]
emojis: Option<Vec<Emoji>>,
#[serde(skip_serializing_if = "Option::is_none")]
voice_states: Option<Vec<ChannelVoiceState>>,
#[serde(skip_serializing_if = "Option::is_none")]
user_settings: Option<UserSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
channel_unreads: Option<Vec<ChannelUnread>>,
#[serde(skip_serializing_if = "Option::is_none")]
policy_changes: Option<Vec<PolicyChange>>,
},
/// Ping response
Pong {
data: Ping,
},
/// New message
Message(Message),
/// Update existing message
MessageUpdate {
id: String,
channel: String,
data: PartialMessage,
#[serde(default)]
clear: Vec<FieldsMessage>,
},
/// Append information to existing message
MessageAppend {
id: String,
channel: String,
append: AppendMessage,
},
/// Delete message
MessageDelete {
id: String,
channel: String,
},
/// New reaction to a message
MessageReact {
id: String,
channel_id: String,
user_id: String,
emoji_id: String,
},
/// Remove user's reaction from message
MessageUnreact {
id: String,
channel_id: String,
user_id: String,
emoji_id: String,
},
/// Remove a reaction from message
MessageRemoveReaction {
id: String,
channel_id: String,
emoji_id: String,
},
/// Bulk delete messages
BulkMessageDelete {
channel: String,
ids: Vec<String>,
},
/// New server
ServerCreate {
id: String,
server: Server,
channels: Vec<Channel>,
emojis: Vec<Emoji>,
voice_states: Vec<ChannelVoiceState>,
},
/// Update existing server
ServerUpdate {
id: String,
data: PartialServer,
#[serde(default)]
clear: Vec<FieldsServer>,
},
/// Delete server
ServerDelete {
id: String,
},
/// Update existing server member
ServerMemberUpdate {
id: MemberCompositeKey,
data: PartialMember,
#[serde(default)]
clear: Vec<FieldsMember>,
},
/// User joins server
ServerMemberJoin {
id: String,
// Deprecated: use member.id.user
#[deprecated = "Use member.id.user instead"]
user: String,
member: Member,
},
/// User left server
ServerMemberLeave {
id: String,
user: String,
reason: RemovalIntention,
},
/// Server role created or updated
ServerRoleUpdate {
id: String,
role_id: String,
data: PartialRole,
#[serde(default)]
clear: Vec<FieldsRole>,
},
/// Server role deleted
ServerRoleDelete {
id: String,
role_id: String,
},
/// Server roles ranks updated
ServerRoleRanksUpdate {
id: String,
ranks: Vec<String>,
},
/// Update existing user
UserUpdate {
id: String,
data: PartialUser,
#[serde(default)]
clear: Vec<FieldsUser>,
event_id: Option<String>,
},
/// Relationship with another user changed
UserRelationship {
id: String,
user: User,
},
/// Settings updated remotely
UserSettingsUpdate {
id: String,
update: UserSettings,
},
/// User has been platform banned or deleted their account
///
/// Clients should remove the following associated data:
/// - Messages
/// - DM Channels
/// - Relationships
/// - Server Memberships
///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe {
user_id: String,
flags: i32,
},
/// New emoji
EmojiCreate(Emoji),
/// Update existing emoji
EmojiUpdate {
id: String,
data: PartialEmoji,
},
/// Delete emoji
EmojiDelete {
id: String,
},
/// New report
ReportCreate(Report),
/// New channel
ChannelCreate(Channel),
/// Update existing channel
ChannelUpdate {
id: String,
data: PartialChannel,
#[serde(default)]
clear: Vec<FieldsChannel>,
},
/// Delete channel
ChannelDelete {
id: String,
},
/// User joins a group
ChannelGroupJoin {
id: String,
user: String,
},
/// User leaves a group
ChannelGroupLeave {
id: String,
user: String,
},
/// User started typing in a channel
ChannelStartTyping {
id: String,
user: String,
},
/// User stopped typing in a channel
ChannelStopTyping {
id: String,
user: String,
},
/// User acknowledged message in channel
ChannelAck {
id: String,
user: String,
message_id: String,
},
/// New webhook
WebhookCreate(Webhook),
/// Update existing webhook
WebhookUpdate {
id: String,
data: PartialWebhook,
remove: Vec<FieldsWebhook>,
},
/// Delete webhook
WebhookDelete {
id: String,
},
/// Auth events
CreateAccount {
account: Account,
},
CreateSession {
session: Session,
},
DeleteSession {
user_id: String,
session_id: String,
},
DeleteAllSessions {
user_id: String,
exclude_session_id: Option<String>,
},
/// Voice events
VoiceChannelJoin {
id: String,
state: UserVoiceState,
},
VoiceChannelLeave {
id: String,
user: String,
},
VoiceChannelMove {
user: String,
from: String,
to: String,
state: UserVoiceState,
},
UserVoiceStateUpdate {
id: String,
channel_id: String,
data: PartialUserVoiceState,
},
UserMoveVoiceChannel {
node: String,
from: String,
to: String,
token: String,
},
/// User's active slowmodes
UserSlowmodes {
slowmodes: Vec<ChannelSlowmode>,
},
}
impl EventV1 {
/// Publish helper wrapper
pub async fn p(self, channel: String) {
#[cfg(not(debug_assertions))]
redis_kiss::p(channel, self).await;
#[cfg(debug_assertions)]
info!("Publishing event to {channel}: {self:?}");
#[cfg(debug_assertions)]
redis_kiss::publish(channel, self).await.unwrap();
}
/// Publish user event
pub async fn p_user(self, id: String, db: &Database) {
self.clone().p(id.clone()).await;
// TODO: this should be captured by member list in the future and not immediately fanned out to users
if let Ok(members) = db.fetch_all_memberships(&id).await {
for member in members {
self.clone().server(member.id.server).await;
}
}
}
/// Publish private event
pub async fn private(self, id: String) {
self.p(format!("{id}!")).await;
}
/// Publish server member event
pub async fn server(self, id: String) {
self.p(format!("{id}u")).await;
}
/// Publish internal global event
pub async fn global(self) {
self.p("global".to_string()).await;
}
}

View File

@@ -1,88 +0,0 @@
use std::collections::HashMap;
use revolt_models::v0::PushNotification;
use serde::{Deserialize, Serialize};
use crate::User;
#[derive(Serialize, Deserialize)]
pub struct MessageSentPayload {
pub notification: PushNotification,
pub users: Vec<String>,
}
#[derive(Serialize, Deserialize)]
pub struct MassMessageSentPayload {
pub notifications: Vec<PushNotification>,
pub server_id: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct FRAcceptedPayload {
pub accepted_user: User,
pub user: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct FRReceivedPayload {
pub from_user: User,
pub user: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct GenericPayload {
pub title: String,
pub body: String,
pub icon: Option<String>,
pub user: User,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DmCallPayload {
pub initiator_id: String,
pub channel_id: String,
pub started_at: Option<String>,
pub ended: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct InternalDmCallPayload {
pub payload: DmCallPayload,
pub recipients: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
#[allow(clippy::large_enum_variant)]
pub enum PayloadKind {
MessageNotification(PushNotification),
FRAccepted(FRAcceptedPayload),
FRReceived(FRReceivedPayload),
BadgeUpdate(usize),
Generic(GenericPayload),
DmCallStartEnd(DmCallPayload),
}
#[derive(Serialize, Deserialize)]
pub struct PayloadToService {
pub notification: PayloadKind,
pub user_id: String,
pub session_id: String,
pub token: String,
pub extras: HashMap<String, String>,
}
#[derive(Serialize, Deserialize)]
pub struct AckPayload {
pub user_id: String,
pub channel_id: String,
pub message_id: String,
}
/// This is not the same as the AckPayload above, as the state for this event is stored in redis to allow for state updates while the event is queued.
#[derive(Serialize, Deserialize, Debug)]
pub struct AckEventPayload {
pub user_id: String,
pub channel_id: Option<String>,
pub server_id: Option<String>,
}

View File

@@ -1,127 +0,0 @@
#[macro_use]
extern crate serde;
#[macro_use]
extern crate async_recursion;
#[macro_use]
extern crate async_trait;
#[macro_use]
extern crate log;
#[macro_use]
extern crate revolt_optional_struct;
#[macro_use]
extern crate revolt_result;
pub use iso8601_timestamp;
#[cfg(feature = "mongodb")]
pub use mongodb;
#[cfg(feature = "mongodb")]
#[macro_use]
extern crate bson;
#[cfg(not(feature = "async-std-runtime"))]
compile_error!("async-std-runtime feature must be enabled.");
#[macro_export]
#[cfg(debug_assertions)]
macro_rules! query {
( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => {
Ok($self.$type($collection, $($rest),+).await.unwrap())
};
}
#[macro_export]
#[cfg(not(debug_assertions))]
macro_rules! query {
( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => {
$self.$type($collection, $($rest),+).await
.map_err(|err| {
revolt_config::capture_internal_error!(err);
create_database_error!(stringify!($type), $collection)
})
};
}
macro_rules! database_derived {
( $( $item:item )+ ) => {
$(
#[derive(Clone)]
$item
)+
};
}
macro_rules! auto_derived {
( $( $item:item )+ ) => {
$(
#[derive(Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
$item
)+
};
}
macro_rules! auto_derived_partial {
( $item:item, $name:expr ) => {
#[derive(OptionalStruct, Serialize, Deserialize, Debug, Clone, Eq, PartialEq)]
#[optional_derive(Serialize, Deserialize, Debug, Clone, Default, Eq, PartialEq)]
#[optional_name = $name]
#[opt_skip_serializing_none]
#[opt_some_priority]
$item
};
}
mod drivers;
pub use drivers::*;
#[cfg(test)]
macro_rules! database_test {
( | $db: ident | $test:expr ) => {
let db = $crate::DatabaseInfo::Test(format!(
"{}:{}",
file!().replace('/', "_").replace(".rs", ""),
line!()
))
.connect()
.await
.expect("Database connection failed.");
db.drop_database().await;
#[allow(clippy::redundant_closure_call)]
(|$db: $crate::Database| $test)(db.clone()).await;
db.drop_database().await
};
}
mod models;
pub mod util;
pub use models::*;
pub mod events;
#[cfg(feature = "tasks")]
pub mod tasks;
mod amqp;
pub use amqp::amqp::AMQP;
#[cfg(feature = "voice")]
pub mod voice;
/// Utility function to check if a boolean value is false
pub fn if_false(t: &bool) -> bool {
!t
}
/// Utility function to check if an option doesnt contain true
pub fn if_option_false(t: &Option<bool>) -> bool {
t != &Some(true)
}

View File

@@ -1,5 +0,0 @@
mod model;
mod ops;
pub use model::*;
pub use ops::*;

View File

@@ -1,25 +0,0 @@
use crate::{if_false, Database};
use revolt_result::Result;
auto_derived_partial!(
/// Account invite ticket
pub struct AccountInvite {
/// Invite code
#[serde(rename = "_id")]
pub id: String,
/// Whether this invite ticket has been used
#[serde(skip_serializing_if = "if_false", default)]
pub used: bool,
/// User ID that this invite was claimed by
#[serde(skip_serializing_if = "Option::is_none")]
pub claimed_by: Option<String>,
},
"PartialAccountInvite"
);
impl AccountInvite {
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_account_invite(self).await
}
}

View File

@@ -1,16 +0,0 @@
use revolt_result::Result;
use crate::AccountInvite;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractAccountInvites: Sync + Send {
/// Find invite by id
async fn fetch_account_invite(&self, id: &str) -> Result<AccountInvite>;
/// Save invite
async fn save_account_invite(&self, invite: &AccountInvite) -> Result<()>;
}

View File

@@ -1,31 +0,0 @@
use crate::{AbstractAccountInvites, AccountInvite, MongoDb};
use bson::to_document;
use mongodb::options::UpdateOptions;
use revolt_result::Result;
const COL: &str = "account_invites";
#[async_trait]
impl AbstractAccountInvites for MongoDb {
/// Find invite by id
async fn fetch_account_invite(&self, id: &str) -> Result<AccountInvite> {
query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(InvalidInvite))
}
/// Save invite
async fn save_account_invite(&self, invite: &AccountInvite) -> Result<()> {
self.col::<AccountInvite>(COL)
.update_one(
doc! {
"_id": &invite.id
},
doc! {
"$set": to_document(invite).map_err(|_| create_database_error!("to_document", COL))?,
},
)
.with_options(UpdateOptions::builder().upsert(true).build())
.await
.map_err(|_| create_database_error!("upsert_one", COL))
.map(|_| ())
}
}

View File

@@ -1,21 +0,0 @@
use crate::{AbstractAccountInvites, AccountInvite, ReferenceDb};
use revolt_result::Result;
#[async_trait]
impl AbstractAccountInvites for ReferenceDb {
/// Find invite by id
async fn fetch_account_invite(&self, id: &str) -> Result<AccountInvite> {
let invites = self.account_invites.lock().await;
invites
.get(id)
.cloned()
.ok_or_else(|| create_error!(InvalidInvite))
}
/// Save invite
async fn save_account_invite(&self, invite: &AccountInvite) -> Result<()> {
let mut invites = self.account_invites.lock().await;
invites.insert(invite.id.to_string(), invite.clone());
Ok(())
}
}

View File

@@ -1,22 +0,0 @@
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
use revolt_result::{Error, Result};
use crate::{Account, Database, Session};
#[async_trait]
impl<S> FromRequestParts<S> for Account
where
Database: FromRef<S>,
S: Send + Sync
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let db = Database::from_ref(state);
let session = Session::from_request_parts(parts, state).await?;
db.fetch_account(&session.user_id).await
}
}

View File

@@ -1,11 +0,0 @@
#[cfg(feature = "axum-impl")]
mod axum;
mod model;
mod ops;
#[cfg(feature = "rocket-impl")]
mod rocket;
#[cfg(feature = "rocket-impl")]
mod schema;
pub use model::*;
pub use ops::*;

View File

@@ -1,656 +0,0 @@
use iso8601_timestamp::{Duration, Timestamp};
use nanoid::nanoid;
use revolt_config::config;
use revolt_result::Result;
use serde_json::json;
use crate::{
events::client::EventV1,
util::{
email::{email_templates, normalise_email, send_email},
password::hash_password,
},
Database, MFATicket, Session,
};
use revolt_models::v0;
auto_derived_partial!(
/// Account model
pub struct Account {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// User's email
pub email: String,
/// Normalised email
///
/// (see https://github.com/insertish/authifier/#how-does-authifier-work)
pub email_normalised: String,
/// Argon2 hashed password
pub password: String,
/// Whether the account is disabled
#[serde(default)]
pub disabled: bool,
/// Email verification status
pub verification: EmailVerification,
/// Password reset information
pub password_reset: Option<PasswordReset>,
/// Account deletion information
pub deletion: Option<DeletionInfo>,
/// Account lockout
pub lockout: Option<Lockout>,
/// Multi-factor authentication information
pub mfa: MultiFactorAuthentication,
},
"PartialAccount"
);
auto_derived!(
/// Email verification status
#[serde(tag = "status")]
pub enum EmailVerification {
/// Account is verified
Verified,
/// Pending email verification
Pending { token: String, expiry: Timestamp },
/// Moving to a new email
Moving {
new_email: String,
token: String,
expiry: Timestamp,
},
}
/// Password reset information
pub struct PasswordReset {
/// Token required to change password
pub token: String,
/// Time at which this token expires
pub expiry: Timestamp,
}
/// Account deletion information
#[serde(tag = "status")]
pub enum DeletionInfo {
/// The user must confirm deletion by email
WaitingForVerification { token: String, expiry: Timestamp },
/// The account is scheduled for deletion
Scheduled { after: Timestamp },
/// This account was deleted
Deleted,
}
/// Lockout information
pub struct Lockout {
/// Attempt counter
pub attempts: i32,
/// Time at which this lockout expires
pub expiry: Option<Timestamp>,
}
/// MFA configuration
#[derive(Default)]
pub struct MultiFactorAuthentication {
/// Allow password-less email OTP login
/// (1-Factor)
// #[serde(skip_serializing_if = "is_false", default)]
// pub enable_email_otp: bool,
/// Allow trusted handover
/// (1-Factor)
// #[serde(skip_serializing_if = "is_false", default)]
// pub enable_trusted_handover: bool,
/// Allow email MFA
/// (2-Factor)
// #[serde(skip_serializing_if = "is_false", default)]
// pub enable_email_mfa: bool,
/// TOTP MFA token, enabled if present
/// (2-Factor)
#[serde(skip_serializing_if = "Totp::is_empty", default)]
pub totp_token: Totp,
/// Security Key MFA token, enabled if present
/// (2-Factor)
// #[serde(skip_serializing_if = "Option::is_none")]
// pub security_key_token: Option<String>,
/// Recovery codes
#[serde(skip_serializing_if = "Vec::is_empty", default)]
pub recovery_codes: Vec<String>,
}
/// MFA method
#[derive(Hash)]
pub enum MFAMethod {
Password,
Recovery,
Totp,
}
#[derive(Default)]
#[serde(tag = "status")]
pub enum Totp {
/// Disabled
#[default]
Disabled,
/// Waiting for user activation
Pending { secret: String },
/// Required on account
Enabled { secret: String },
}
);
impl MultiFactorAuthentication {
// Check whether MFA is in-use
pub fn is_active(&self) -> bool {
matches!(self.totp_token, Totp::Enabled { .. })
}
// Check whether there are still usable recovery codes
pub fn has_recovery(&self) -> bool {
!self.recovery_codes.is_empty()
}
// Get available MFA methods
pub fn get_methods(&self) -> Vec<MFAMethod> {
if let Totp::Enabled { .. } = self.totp_token {
let mut methods = vec![MFAMethod::Totp];
if self.has_recovery() {
methods.push(MFAMethod::Recovery);
}
methods
} else {
vec![MFAMethod::Password]
}
}
// Generate new recovery codes
pub fn generate_recovery_codes(&mut self) {
static ALPHABET: [char; 32] = [
'1', '2', '3', '4', '5', '6', '7', '8', '9', '0', 'a', 'b', 'c', 'd', 'e', 'f', 'g',
'h', 'j', 'k', 'm', 'n', 'p', 'q', 'r', 's', 't', 'v', 'w', 'x', 'y', 'z',
];
let mut codes = vec![];
for _ in 1..=10 {
codes.push(format!(
"{}-{}",
nanoid!(5, &ALPHABET),
nanoid!(5, &ALPHABET)
));
}
self.recovery_codes = codes;
}
// Generate new TOTP secret
pub fn generate_new_totp_secret(&mut self) -> Result<String> {
if let Totp::Enabled { .. } = self.totp_token {
return Err(create_error!(OperationFailed));
}
let secret: [u8; 10] = rand::random();
let secret = base32::encode(base32::Alphabet::RFC4648 { padding: false }, &secret);
self.totp_token = Totp::Pending {
secret: secret.clone(),
};
Ok(secret)
}
/// Enable TOTP using a given MFA response
pub fn enable_totp(&mut self, response: v0::MFAResponse) -> Result<()> {
if let v0::MFAResponse::Totp { totp_code } = response {
let code = self.totp_token.generate_code()?;
if code == totp_code {
let mut totp = Totp::Disabled;
std::mem::swap(&mut totp, &mut self.totp_token);
if let Totp::Pending { secret } = totp {
self.totp_token = Totp::Enabled { secret };
Ok(())
} else {
Err(create_error!(OperationFailed))
}
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(InvalidToken))
}
}
}
impl Totp {
/// Whether TOTP information is empty
pub fn is_empty(&self) -> bool {
matches!(self, Totp::Disabled)
}
/// Whether TOTP is disabled
pub fn is_disabled(&self) -> bool {
!matches!(self, Totp::Enabled { .. })
}
// Generate a TOTP code from secret
pub fn generate_code(&self) -> Result<String> {
if let Totp::Enabled { secret } | Totp::Pending { secret } = &self {
let seconds: u64 = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(totp_lite::totp_custom::<totp_lite::Sha1>(
totp_lite::DEFAULT_STEP,
6,
&base32::decode(base32::Alphabet::RFC4648 { padding: false }, secret)
.expect("valid base32 secret"),
seconds,
))
} else {
Err(create_error!(OperationFailed))
}
}
}
impl Account {
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_account(self).await
}
/// Create a new account
pub async fn new(
db: &Database,
email: String,
plaintext_password: String,
verify_email: bool,
) -> Result<Account> {
// Get a normalised representation of the user's email
let email_normalised = normalise_email(email.clone());
// Try to find an existing account
if let Some(mut account) = db
.fetch_account_by_normalised_email(&email_normalised)
.await?
{
// Resend account verification or send password reset
if let EmailVerification::Pending { .. } = &account.verification {
account.start_email_verification(db).await?;
} else {
account.start_password_reset(db, true).await?;
}
Ok(account)
} else {
// Hash the user's password
let password = hash_password(plaintext_password)?;
// Create a new account
let mut account = Account {
id: ulid::Ulid::new().to_string(),
email,
email_normalised,
password,
disabled: false,
verification: EmailVerification::Verified,
password_reset: None,
deletion: None,
lockout: None,
mfa: Default::default(),
};
// Send email verification
if verify_email {
account.start_email_verification(db).await?;
} else {
account.save(db).await?;
}
// Create and push event
EventV1::CreateAccount {
account: account.clone(),
}
.global()
.await;
Ok(account)
}
}
/// Create a new session
pub async fn create_session(&self, db: &Database, name: String) -> Result<Session> {
let config = config().await;
let session = Session {
id: ulid::Ulid::new().to_string(),
token: nanoid!(64),
user_id: self.id.clone(),
name,
last_seen: Timestamp::now_utc(),
origin: Some(config.environment),
subscription: None,
};
// Save to database
db.save_session(&session).await?;
// Create and push event
EventV1::CreateSession {
session: session.clone(),
}
.global()
.await;
Ok(session)
}
/// Send account verification email
pub async fn start_email_verification(&mut self, db: &Database) -> Result<()> {
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let token = nanoid!(32);
let url = format!("{}{}", templates.verify.url, token);
send_email(
&config.api.smtp,
self.email.clone(),
&templates.verify,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.verification = EmailVerification::Pending {
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_verification,
))
.unwrap(),
};
} else {
self.verification = EmailVerification::Verified;
}
self.save(db).await
}
/// Send account verification to new email
pub async fn start_email_move(&mut self, db: &Database, new_email: String) -> Result<()> {
// This method should and will never be called on an unverified account,
// but just validate this just in case.
if let EmailVerification::Pending { .. } = self.verification {
return Err(create_error!(UnverifiedAccount));
}
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let token = nanoid!(32);
let url = format!("{}{}", templates.verify.url, token);
send_email(
&config.api.smtp,
new_email.clone(),
&templates.verify,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.verification = EmailVerification::Moving {
new_email,
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_verification,
))
.unwrap(),
};
} else {
self.email_normalised = normalise_email(new_email.clone());
self.email = new_email;
}
self.save(db).await
}
/// Send password reset email
pub async fn start_password_reset(
&mut self,
db: &Database,
existing_account: bool,
) -> Result<()> {
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let template = if existing_account {
&templates.reset_existing
} else {
&templates.reset
};
let token = nanoid!(32);
let url = format!("{}{}", template.url, token);
send_email(
&config.api.smtp,
self.email.clone(),
template,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.password_reset = Some(PasswordReset {
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_password_reset,
))
.unwrap(),
});
} else {
return Err(create_error!(OperationFailed));
}
self.save(db).await
}
/// Begin account deletion process by sending confirmation email
///
/// If email verification is not on, the account will be marked for deletion instantly
pub async fn start_account_deletion(&mut self, db: &Database) -> Result<()> {
let config = config().await;
if !config.api.smtp.host.is_empty() {
let templates = email_templates().await;
let token = nanoid!(32);
let url = format!("{}{}", templates.deletion.url, token);
send_email(
&config.api.smtp,
self.email.clone(),
&templates.deletion,
json!({
"email": self.email.clone(),
"url": url
}),
)?;
self.deletion = Some(DeletionInfo::WaitingForVerification {
token,
expiry: Timestamp::now_utc()
.checked_add(Duration::seconds(
config.api.smtp.expiry.expire_password_reset,
))
.unwrap(),
});
self.save(db).await
} else {
self.schedule_deletion(db).await
}
}
/// Verify a user's password is correct
pub fn verify_password(&self, plaintext_password: &str) -> Result<()> {
argon2::verify_encoded(&self.password, plaintext_password.as_bytes())
.map(|v| {
if v {
Ok(())
} else {
Err(create_error!(InvalidCredentials))
}
})
// To prevent user enumeration, we should ignore
// the error and pretend the password is wrong.
.map_err(|_| create_error!(InvalidCredentials))?
}
/// Validate an MFA response
pub async fn consume_mfa_response(
&mut self,
db: &Database,
response: v0::MFAResponse,
ticket: Option<MFATicket>,
) -> Result<()> {
let allowed_methods = self.mfa.get_methods();
match response {
v0::MFAResponse::Password { password } => {
if allowed_methods.contains(&MFAMethod::Password) {
self.verify_password(&password)
} else {
Err(create_error!(DisallowedMFAMethod))
}
}
v0::MFAResponse::Totp { totp_code } => {
if allowed_methods.contains(&MFAMethod::Totp) {
if let Totp::Enabled { .. } = &self.mfa.totp_token {
// Use TOTP code at generation if applicable
if let Some(ticket) = ticket {
if let Some(code) = ticket.last_totp_code {
if code == totp_code {
return Ok(());
}
}
}
// Otherwise read current TOTP token
if self.mfa.totp_token.generate_code()? == totp_code {
Ok(())
} else {
Err(create_error!(InvalidToken))
}
} else {
unreachable!()
}
} else {
Err(create_error!(DisallowedMFAMethod))
}
}
v0::MFAResponse::Recovery { recovery_code } => {
if allowed_methods.contains(&MFAMethod::Recovery) {
if let Some(index) = self
.mfa
.recovery_codes
.iter()
.position(|x| x == &recovery_code)
{
self.mfa.recovery_codes.remove(index);
self.save(db).await
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(DisallowedMFAMethod))
}
}
}
}
/// Delete all sessions for an account
pub async fn delete_all_sessions(
&self,
db: &Database,
exclude_session_id: Option<String>,
) -> Result<()> {
db.delete_all_sessions(&self.id, exclude_session_id.clone())
.await?;
// Create and push event
EventV1::DeleteAllSessions {
user_id: self.id.clone(),
exclude_session_id,
}
.private(self.id.clone())
.await;
Ok(())
}
/// Disable an account
pub async fn disable(&mut self, db: &Database) -> Result<()> {
self.disabled = true;
self.delete_all_sessions(db, None).await?;
self.save(db).await
}
/// Schedule an account for deletion
pub async fn schedule_deletion(&mut self, db: &Database) -> Result<()> {
self.deletion = Some(DeletionInfo::Scheduled {
after: Timestamp::now_utc()
.checked_add(Duration::weeks(1))
.unwrap(),
});
self.disable(db).await
}
/// Removes all information from the account and marks it as fully deleted
pub async fn mark_deleted(&mut self, db: &Database) -> Result<()> {
self.email = format!("Deleted User {}", &self.id);
self.email_normalised = format!("Deleted User {}", &self.id);
self.deletion = Some(DeletionInfo::Deleted);
self.save(db).await?;
Ok(())
}
}

View File

@@ -1,34 +0,0 @@
use revolt_result::Result;
use crate::Account;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractAccounts: Sync + Send {
/// Find account by id
async fn fetch_account(&self, id: &str) -> Result<Account>;
/// Find account by normalised email
async fn fetch_account_by_normalised_email(
&self,
normalised_email: &str,
) -> Result<Option<Account>>;
/// Find account with active pending email verification
async fn fetch_account_with_email_verification(&self, token: &str) -> Result<Account>;
/// Find account with active password reset
async fn fetch_account_with_password_reset(&self, token: &str) -> Result<Account>;
/// Find account with active deletion token
async fn fetch_account_with_deletion_token(&self, token: &str) -> Result<Account>;
/// Find accounts which are due to be deleted
async fn fetch_accounts_due_for_deletion(&self) -> Result<Vec<Account>>;
// Save account
async fn save_account(&self, account: &Account) -> Result<()>;
}

View File

@@ -1,118 +0,0 @@
use crate::{AbstractAccounts, Account, MongoDb};
use bson::{to_bson, to_document};
use iso8601_timestamp::Timestamp;
use mongodb::options::{Collation, CollationStrength, FindOneOptions, UpdateOptions};
use revolt_result::Result;
const COL: &str = "accounts";
#[async_trait]
impl AbstractAccounts for MongoDb {
/// Find account by id
async fn fetch_account(&self, id: &str) -> Result<Account> {
query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(UnknownUser))
}
/// Find account by normalised email
async fn fetch_account_by_normalised_email(
&self,
normalised_email: &str,
) -> Result<Option<Account>> {
query!(
self,
find_one_with_options,
COL,
doc! {
"email_normalised": normalised_email
},
FindOneOptions::builder()
.collation(
Collation::builder()
.locale("en")
.strength(CollationStrength::Secondary)
.build(),
)
.build()
)
}
/// Find account with active pending email verification
async fn fetch_account_with_email_verification(&self, token: &str) -> Result<Account> {
query!(
self,
find_one,
COL,
doc! {
"verification.token": token,
"verification.expiry": {
"$gte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)?
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active password reset
async fn fetch_account_with_password_reset(&self, token: &str) -> Result<Account> {
query!(
self,
find_one,
COL,
doc! {
"password_reset.token": token,
"password_reset.expiry": {
"$gte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)?
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active deletion token
async fn fetch_account_with_deletion_token(&self, token: &str) -> Result<Account> {
query!(
self,
find_one,
COL,
doc! {
"deletion.token": token,
"deletion.expiry": {
"$gte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)?
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find accounts which are due to be deleted
async fn fetch_accounts_due_for_deletion(&self) -> Result<Vec<Account>> {
query!(
self,
find,
COL,
doc! {
"deletion.status": "Scheduled",
"deletion.after": {
"$lte": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)
}
// Save account
async fn save_account(&self, account: &Account) -> Result<()> {
self.col::<Account>(COL)
.update_one(
doc! {
"_id": &account.id
},
doc! {
"$set": to_document(account).map_err(|_| create_database_error!("to_document", COL))?
},
)
.with_options(UpdateOptions::builder().upsert(true).build())
.await
.map_err(|_| create_database_error!("find_one", COL))
.map(|_| ())
}
}

View File

@@ -1,99 +0,0 @@
use crate::{AbstractAccounts, Account, DeletionInfo, EmailVerification, ReferenceDb};
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
#[async_trait]
impl AbstractAccounts for ReferenceDb {
/// Find account by id
async fn fetch_account(&self, id: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.get(id)
.cloned()
.ok_or_else(|| create_error!(UnknownUser))
}
/// Find account by normalised email
async fn fetch_account_by_normalised_email(
&self,
normalised_email: &str,
) -> Result<Option<Account>> {
let accounts = self.accounts.lock().await;
Ok(accounts
.values()
.find(|account| account.email_normalised == normalised_email)
.cloned())
}
/// Find account with active pending email verification
async fn fetch_account_with_email_verification(&self, token_to_match: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.values()
.find(|account| match &account.verification {
EmailVerification::Pending { token, .. }
| EmailVerification::Moving { token, .. } => token == token_to_match,
_ => false,
})
.cloned()
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active password reset
async fn fetch_account_with_password_reset(&self, token: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.values()
.find(|account| {
if let Some(reset) = &account.password_reset {
reset.token == token
} else {
false
}
})
.cloned()
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find account with active deletion token
async fn fetch_account_with_deletion_token(&self, token_to_match: &str) -> Result<Account> {
let accounts = self.accounts.lock().await;
accounts
.values()
.find(|account| {
if let Some(DeletionInfo::WaitingForVerification { token, .. }) = &account.deletion
{
token == token_to_match
} else {
false
}
})
.cloned()
.ok_or_else(|| create_error!(InvalidToken))
}
/// Find accounts which are due to be deleted
async fn fetch_accounts_due_for_deletion(&self) -> Result<Vec<Account>> {
let now = Timestamp::now_utc();
let accounts = self.accounts.lock().await;
Ok(accounts
.values()
.filter(|account| {
if let Some(DeletionInfo::Scheduled { after }) = &account.deletion {
after <= &now
} else {
false
}
})
.cloned()
.collect())
}
// Save account
async fn save_account(&self, account: &Account) -> Result<()> {
let mut accounts = self.accounts.lock().await;
accounts.insert(account.id.to_string(), account.clone());
Ok(())
}
}

View File

@@ -1,32 +0,0 @@
use crate::{Account, Database, Session};
use revolt_result::Error;
use rocket::{
http::Status,
request::{FromRequest, Outcome},
Request,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Account {
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
match request.guard::<Session>().await {
Outcome::Success(session) => {
if let Ok(account) = request
.rocket()
.state::<Database>()
.expect("`Database`")
.fetch_account(&session.user_id)
.await
{
Outcome::Success(account)
} else {
Outcome::Error((Status::InternalServerError, create_error!(InternalError)))
}
}
Outcome::Forward(_) => unreachable!(),
Outcome::Error(err) => Outcome::Error(err),
}
}
}

View File

@@ -1,32 +0,0 @@
use revolt_okapi::openapi3::{SecurityScheme, SecuritySchemeData};
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
};
use crate::Account;
impl<'r> OpenApiFromRequest<'r> for Account {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Session Token".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Session Token".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-session-token".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authenticate as a user.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}

View File

@@ -1,5 +0,0 @@
mod model;
mod ops;
pub use model::*;
pub use ops::*;

View File

@@ -1,24 +0,0 @@
auto_derived!(
/// Document representing migration information
pub struct MigrationInfo {
/// Unique Id
#[serde(rename = "_id")]
pub id: i32,
/// Current database revision
pub revision: i32,
}
);
#[cfg(test)]
mod tests {
#[async_std::test]
async fn migrate() {
database_test!(|db| async move {
// Initialise the database
db.migrate_database().await.unwrap();
// Migrate the existing database
db.migrate_database().await.unwrap();
});
}
}

View File

@@ -1,13 +0,0 @@
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractMigrations: Sync + Send {
#[cfg(test)]
/// Drop the database
async fn drop_database(&self);
/// Migrate the database
async fn migrate_database(&self) -> Result<(), ()>;
}

View File

@@ -1,363 +0,0 @@
use super::scripts::LATEST_REVISION;
use crate::mongodb::bson::doc;
use crate::mongodb::options::CreateCollectionOptions;
use crate::MongoDb;
pub async fn create_database(db: &MongoDb) {
info!("Creating database.");
let db = db.db();
db.create_collection("accounts")
.await
.expect("Failed to create accounts collection.");
db.create_collection("users")
.await
.expect("Failed to create users collection.");
db.create_collection("channels")
.await
.expect("Failed to create channels collection.");
db.create_collection("messages")
.await
.expect("Failed to create messages collection.");
db.create_collection("servers")
.await
.expect("Failed to create servers collection.");
db.create_collection("server_members")
.await
.expect("Failed to create server_members collection.");
db.create_collection("server_bans")
.await
.expect("Failed to create server_bans collection.");
db.create_collection("channel_invites")
.await
.expect("Failed to create channel_invites collection.");
db.create_collection("channel_unreads")
.await
.expect("Failed to create channel_unreads collection.");
db.create_collection("channel_webhooks")
.await
.expect("Failed to create channel_webhooks collection.");
db.create_collection("migrations")
.await
.expect("Failed to create migrations collection.");
db.create_collection("attachments")
.await
.expect("Failed to create attachments collection.");
db.create_collection("attachment_hashes")
.await
.expect("Failed to create attachment_hashes collection.");
db.create_collection("user_settings")
.await
.expect("Failed to create user_settings collection.");
db.create_collection("policy_changes")
.await
.expect("Failed to create policy_changes collection.");
db.create_collection("safety_reports")
.await
.expect("Failed to create safety_reports collection.");
db.create_collection("safety_snapshots")
.await
.expect("Failed to create safety_snapshots collection.");
db.create_collection("safety_strikes")
.await
.expect("Failed to create safety_strikes collection.");
db.create_collection("bots")
.await
.expect("Failed to create bots collection.");
db.create_collection("ratelimit_events")
.await
.expect("Failed to create ratelimit_events collection.");
db.create_collection("pubsub")
.with_options(
CreateCollectionOptions::builder()
.capped(true)
.size(1_000_000)
.build(),
)
.await
.expect("Failed to create pubsub collection.");
db.create_collection("sessions")
.await
.expect("Failed to create sessions collection.");
db.create_collection("account_invites")
.await
.expect("Failed to create account_invites collection.");
db.create_collection("mfa_tickets")
.await
.expect("Failed to create mfa_tickets collection.");
db.run_command(doc! {
"createIndexes": "users",
"indexes": [
{
"key": {
"username": 1_i32
},
"name": "username",
"unique": false,
"collation": {
"locale": "en",
"strength": 2_i32
}
},
{
"key": {
"username": 1_i32,
"discriminator": 1_i32
},
"name": "username_discriminator",
"unique": true,
"collation": {
"locale": "en",
"strength": 2_i32
}
}
]
})
.await
.expect("Failed to create username index.");
db.run_command(doc! {
"createIndexes": "messages",
"indexes": [
{
"key": {
"content": "text"
},
"name": "content"
},
{
"key": {
"channel": 1_i32,
"_id": 1_i32
},
"name": "channel_id_compound"
},
{
"key": {
"author": 1_i32
},
"name": "author"
},
{
"key": {
"channel": 1_i32,
"pinned": 1_i32
},
"name": "channel_pinned_compound"
},
]
})
.await
.expect("Failed to create message index.");
db.run_command(doc! {
"createIndexes": "channel_unreads",
"indexes": [
{
"key": {
"_id.channel": 1_i32,
"_id.user": 1_i32,
},
"name": "compound_id"
},
{
"key": {
"_id.user": 1_i32,
},
"name": "user_id"
}
]
})
.await
.expect("Failed to create channel_unreads index.");
db.run_command(doc! {
"createIndexes": "server_members",
"indexes": [
{
"key": {
"_id.server": 1_i32,
"_id.user": 1_i32,
},
"name": "compound_id"
},
{
"key": {
"_id.user": 1_i32,
},
"name": "user_id"
}
]
})
.await
.expect("Failed to create server_members index.");
db.run_command(doc! {
"createIndexes": "attachments",
"indexes": [
{
"key": {
"hash": 1_i32
},
"name": "hash"
},
{
"key": {
"used_for.id": 1_i32
},
"name": "used_for_id"
}
]
})
.await
.expect("Failed to create attachments index.");
db.run_command(doc! {
"createIndexes": "attachment_hashes",
"indexes": [
{
"key": {
"processed_hash": 1_i32
},
"name": "processed_hash"
}
]
})
.await
.expect("Failed to create attachment_hashes index.");
db.collection("migrations")
.insert_one(doc! {
"_id": 0_i32,
"revision": LATEST_REVISION
})
.await
.expect("Failed to save migration info.");
db.run_command(doc! {
"createIndexes": "ratelimit_events",
"indexes": [
{
"key": {
"_id": 1_i32,
"target_id": 1_i32,
"event_type": 1_i32,
},
"name": "compound_key"
}
]
})
.await
.expect("Failed to create ratelimit_events index.");
db.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"email": 1
},
"name": "email",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"email_normalised": 1
},
"name": "email_normalised",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"verification.token": 1
},
"name": "email_verification"
},
{
"key": {
"password_reset.token": 1
},
"name": "password_reset"
},
{
"key": {
"deletion.token": 1
},
"name": "account_deletion"
}
]
})
.await
.unwrap();
db.run_command(doc! {
"createIndexes": "sessions",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
},
{
"key": {
"user_id": 1
},
"name": "user_id"
}
]
})
.await
.unwrap();
db.run_command(doc! {
"createIndexes": "mfa_tickets",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
}
]
})
.await
.unwrap();
info!("Created database.");
}

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