Merge remote-tracking branch 'origin/main' into feat/elasticsearch

Signed-off-by: Zomatree <me@zomatree.live>
This commit is contained in:
Zomatree
2026-06-25 06:19:17 +01:00
241 changed files with 118635 additions and 7140 deletions

46
.github/workflows/docker-cleanup.yaml vendored Normal file
View File

@@ -0,0 +1,46 @@
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

@@ -5,8 +5,6 @@ on:
tags: tags:
- "*" - "*"
pull_request: pull_request:
paths:
- "Dockerfile"
workflow_dispatch: workflow_dispatch:
permissions: permissions:
@@ -19,9 +17,9 @@ concurrency:
jobs: jobs:
base: base:
name: Test base image build name: Test base image build (fork)
runs-on: arc-runner-set runs-on: arc-runner-set
if: github.event_name == 'pull_request' if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
steps: steps:
# Configure build environment # Configure build environment
- name: Checkout - name: Checkout
@@ -42,7 +40,7 @@ jobs:
publish: publish:
runs-on: arc-runner-set runs-on: arc-runner-set
if: github.event_name != 'pull_request' if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
name: Publish Docker images name: Publish Docker images
steps: steps:
# Configure build environment # Configure build environment
@@ -59,6 +57,15 @@ jobs:
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine base image tag
id: base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "tag=pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
# Build the image # Build the image
- name: Build base image - name: Build base image
uses: docker/build-push-action@v4 uses: docker/build-push-action@v4
@@ -66,7 +73,9 @@ jobs:
context: . context: .
push: true push: true
platforms: linux/amd64,linux/arm64 platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository_owner }}/base:latest 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
# stoatchat/api # stoatchat/api
- name: Docker meta - name: Docker meta
@@ -84,7 +93,7 @@ jobs:
file: crates/delta/Dockerfile file: crates/delta/Dockerfile
tags: ${{ steps.meta-delta.outputs.tags }} tags: ${{ steps.meta-delta.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-delta.outputs.labels }} labels: ${{ steps.meta-delta.outputs.labels }}
# stoatchat/events # stoatchat/events
@@ -103,7 +112,7 @@ jobs:
file: crates/bonfire/Dockerfile file: crates/bonfire/Dockerfile
tags: ${{ steps.meta-bonfire.outputs.tags }} tags: ${{ steps.meta-bonfire.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-bonfire.outputs.labels }} labels: ${{ steps.meta-bonfire.outputs.labels }}
# stoatchat/file-server # stoatchat/file-server
@@ -122,7 +131,7 @@ jobs:
file: crates/services/autumn/Dockerfile file: crates/services/autumn/Dockerfile
tags: ${{ steps.meta-autumn.outputs.tags }} tags: ${{ steps.meta-autumn.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-autumn.outputs.labels }} labels: ${{ steps.meta-autumn.outputs.labels }}
# stoatchat/proxy # stoatchat/proxy
@@ -141,7 +150,7 @@ jobs:
file: crates/services/january/Dockerfile file: crates/services/january/Dockerfile
tags: ${{ steps.meta-january.outputs.tags }} tags: ${{ steps.meta-january.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-january.outputs.labels }} labels: ${{ steps.meta-january.outputs.labels }}
# stoatchat/gifbox # stoatchat/gifbox
@@ -160,7 +169,7 @@ jobs:
file: crates/services/gifbox/Dockerfile file: crates/services/gifbox/Dockerfile
tags: ${{ steps.meta-gifbox.outputs.tags }} tags: ${{ steps.meta-gifbox.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-gifbox.outputs.labels }} labels: ${{ steps.meta-gifbox.outputs.labels }}
# stoatchat/crond # stoatchat/crond
@@ -179,7 +188,7 @@ jobs:
file: crates/daemons/crond/Dockerfile file: crates/daemons/crond/Dockerfile
tags: ${{ steps.meta-crond.outputs.tags }} tags: ${{ steps.meta-crond.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-crond.outputs.labels }} labels: ${{ steps.meta-crond.outputs.labels }}
# stoatchat/pushd # stoatchat/pushd
@@ -198,7 +207,7 @@ jobs:
file: crates/daemons/pushd/Dockerfile file: crates/daemons/pushd/Dockerfile
tags: ${{ steps.meta-pushd.outputs.tags }} tags: ${{ steps.meta-pushd.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-pushd.outputs.labels }} labels: ${{ steps.meta-pushd.outputs.labels }}
# stoatchat/voice-ingress # stoatchat/voice-ingress
@@ -217,5 +226,5 @@ jobs:
file: crates/daemons/voice-ingress/Dockerfile file: crates/daemons/voice-ingress/Dockerfile
tags: ${{ steps.meta-voice-ingress.outputs.tags }} tags: ${{ steps.meta-voice-ingress.outputs.tags }}
build-args: | build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-voice-ingress.outputs.labels }} labels: ${{ steps.meta-voice-ingress.outputs.labels }}

View File

@@ -21,4 +21,6 @@ jobs:
github-token: ${{ secrets.GITHUB_TOKEN }} github-token: ${{ secrets.GITHUB_TOKEN }}
- name: Publish - name: Publish
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
run: mise publish --workspace run: mise publish --workspace

30
.github/workflows/renovate.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# 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

@@ -37,6 +37,7 @@ jobs:
- name: Reference Test - name: Reference Test
env: env:
TEST_DB: REFERENCE TEST_DB: REFERENCE
continue-on-error: ${{ github.ref_name == 'main' }}
run: | run: |
mise test mise test
@@ -44,6 +45,7 @@ jobs:
env: env:
TEST_DB: MONGODB TEST_DB: MONGODB
MONGODB: mongodb://localhost MONGODB: mongodb://localhost
continue-on-error: ${{ github.ref_name == 'main' }}
run: | run: |
mise test mise test

View File

@@ -2,12 +2,12 @@
node = "25.4.0" node = "25.4.0"
pnpm = "10.28.1" pnpm = "10.28.1"
gh = "2.25.0" gh = "2.95.0"
rust = "1.92.0" rust = "1.92.0"
"cargo:cargo-nextest" = "0.9.122" "cargo:cargo-nextest" = "0.9.122"
"github:git-town/git-town" = "22.4.0" "github:git-town/git-town" = "22.7.1"
[settings] [settings]
experimental = true experimental = true

View File

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

View File

@@ -1,5 +1,97 @@
# Changelog # Changelog
## [0.13.7](https://github.com/stoatchat/stoatchat/compare/v0.13.6...v0.13.7) (2026-05-21)
### Bug Fixes
* sanitize emoji input to handle variation selectors ([#774](https://github.com/stoatchat/stoatchat/issues/774)) ([2d308e0](https://github.com/stoatchat/stoatchat/commit/2d308e03d58c19f27b5b4d65dc2a15ef20b56190))
* update mention count badge for channel acks ([#769](https://github.com/stoatchat/stoatchat/issues/769)) ([0d9ae50](https://github.com/stoatchat/stoatchat/commit/0d9ae508d9d2199f0e408b8ca634d20489be6f61))
## [0.13.6](https://github.com/stoatchat/stoatchat/compare/v0.13.5...v0.13.6) (2026-05-18)
### Features
* Update FCM payload for android notifications ([#766](https://github.com/stoatchat/stoatchat/issues/766)) ([acbc087](https://github.com/stoatchat/stoatchat/commit/acbc087982e9aeb05cabc5ab4c9b1291f67490ad))
* user slowmode events ([#760](https://github.com/stoatchat/stoatchat/issues/760)) ([af0d8aa](https://github.com/stoatchat/stoatchat/commit/af0d8aad14dc68d88159d0e1c714077d362e21e4))
### Bug Fixes
* include `minio` region as tests need it ([#761](https://github.com/stoatchat/stoatchat/issues/761)) ([298742d](https://github.com/stoatchat/stoatchat/commit/298742dbad4eafae356f976c56b9db23904b0c3a))
* set env var for publishing crates ([#768](https://github.com/stoatchat/stoatchat/issues/768)) ([018afaf](https://github.com/stoatchat/stoatchat/commit/018afaf38f6330d92dad2a68b640c0cb3f6b639a))
* Use proper headers to determine IP when not behind cloudflare ([#764](https://github.com/stoatchat/stoatchat/issues/764)) ([494c8b7](https://github.com/stoatchat/stoatchat/commit/494c8b7cabaae2a51039a7a5b559d5e2e5279554))
* voice ingress crashing due to new Result in AMQP::new_auto() ([#765](https://github.com/stoatchat/stoatchat/issues/765)) ([2871632](https://github.com/stoatchat/stoatchat/commit/2871632382395cb20cbe0047c542d3ac31ff3f03))
### Miscellaneous Chores
* switch to lapin ([#767](https://github.com/stoatchat/stoatchat/issues/767)) ([5b19853](https://github.com/stoatchat/stoatchat/commit/5b1985381ae829a92c80a19e91a414cd9dc4de93))
## [0.13.5](https://github.com/stoatchat/stoatchat/compare/v0.13.4...v0.13.5) (2026-05-17)
### Bug Fixes
* dont panic on hash missing when deleting files ([#755](https://github.com/stoatchat/stoatchat/issues/755)) ([c902077](https://github.com/stoatchat/stoatchat/commit/c902077cf51076fee11712eb732dc8a8f786fc4b))
## [0.13.4](https://github.com/stoatchat/stoatchat/compare/v0.13.3...v0.13.4) (2026-05-16)
### Bug Fixes
* add TLS feature to livekit-api crate ([#753](https://github.com/stoatchat/stoatchat/issues/753)) ([6cfee1f](https://github.com/stoatchat/stoatchat/commit/6cfee1f601c1e084df7c8f1e7a5e8a560d1dd514))
## [0.13.3](https://github.com/stoatchat/stoatchat/compare/v0.13.2...v0.13.3) (2026-05-15)
### Bug Fixes
* don't automatically set up rabbitmq in delta ([#749](https://github.com/stoatchat/stoatchat/issues/749)) ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
* don't declare queues which seem to cause the backend to crash in prod ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
## [0.13.2](https://github.com/stoatchat/stoatchat/compare/v0.13.1...v0.13.2) (2026-05-11)
### Bug Fixes
* update default exchange to `revolt.default` ([#746](https://github.com/stoatchat/stoatchat/issues/746)) ([fcb8091](https://github.com/stoatchat/stoatchat/commit/fcb8091cd7a00d7f26c798daa33aae4b923b2a8b))
## [0.13.1](https://github.com/stoatchat/stoatchat/compare/v0.13.0...v0.13.1) (2026-05-10)
### Bug Fixes
* amqprs startup bug ([#744](https://github.com/stoatchat/stoatchat/issues/744)) ([1100eaf](https://github.com/stoatchat/stoatchat/commit/1100eaf46f849f2509ae01ac497556ca33bde778))
## [0.13.0](https://github.com/stoatchat/stoatchat/compare/v0.12.1...v0.13.0) (2026-05-08)
### Features
* add embed support for YouTube Shorts ([#734](https://github.com/stoatchat/stoatchat/issues/734)) ([d46c7f7](https://github.com/stoatchat/stoatchat/commit/d46c7f7f3c04524c0639c3e0a122626f8e0b3bf7))
* add emoji rename endpoint ([#714](https://github.com/stoatchat/stoatchat/issues/714)) ([23ad135](https://github.com/stoatchat/stoatchat/commit/23ad1359834bb7d07a460b8678d6a6ebffc73eb0))
* add legal links to root payload ([#733](https://github.com/stoatchat/stoatchat/issues/733)) ([21d8201](https://github.com/stoatchat/stoatchat/commit/21d82018cf84ab0fdd10613d254b9562aea8eea3))
* add role icon support ([#724](https://github.com/stoatchat/stoatchat/issues/724)) ([841985d](https://github.com/stoatchat/stoatchat/commit/841985d3b994df1c6eefab2fc7ecbd77ab22c493))
* Add webhook endpoints for editing and deleting messages ([#682](https://github.com/stoatchat/stoatchat/issues/682)) ([6f3441c](https://github.com/stoatchat/stoatchat/commit/6f3441cf4acac2a8e6e1bf07a279a153b80f7956))
* automatically sanitise usernames on create/update ([#689](https://github.com/stoatchat/stoatchat/issues/689)) ([e937697](https://github.com/stoatchat/stoatchat/commit/e93769786c7669485a659ee471630740d3cea702))
* blacklist private ip ranges and add january domain blocklist ([#731](https://github.com/stoatchat/stoatchat/issues/731)) ([6b41db9](https://github.com/stoatchat/stoatchat/commit/6b41db984bb491b2e58324309cc70d8c14e0b814))
* Rewrite acks ([#741](https://github.com/stoatchat/stoatchat/issues/741)) ([ab5bd47](https://github.com/stoatchat/stoatchat/commit/ab5bd47a39ee889de0b5ae6e7b560620853daead))
### Bug Fixes
* add new_user_hours to configuration limits ([#729](https://github.com/stoatchat/stoatchat/issues/729)) ([279f5d5](https://github.com/stoatchat/stoatchat/commit/279f5d5fd7af2df55902c706859ec07f569cdb1e))
* add reconnection policy to Redis subscriber to prevent ghost state ([#708](https://github.com/stoatchat/stoatchat/issues/708)) ([057f2bb](https://github.com/stoatchat/stoatchat/commit/057f2bb8b359f8b942741a30ff54eeb8fbe3e0b1))
* docker compose file had personal url in it ([#742](https://github.com/stoatchat/stoatchat/issues/742)) ([0719985](https://github.com/stoatchat/stoatchat/commit/0719985ac5636590f91e6f9ec4b68f3eded70c13))
* don't strip ICC from exif ([#735](https://github.com/stoatchat/stoatchat/issues/735)) ([d76a711](https://github.com/stoatchat/stoatchat/commit/d76a71141f3e508f6308ba52fa28eaeb56fb3438))
* dont send notification in fcm ([#721](https://github.com/stoatchat/stoatchat/issues/721)) ([89171e9](https://github.com/stoatchat/stoatchat/commit/89171e9bd0f15711157e78c6eec0fe7b480de93a))
* encode filenames in redirects ([#737](https://github.com/stoatchat/stoatchat/issues/737)) ([9fd7128](https://github.com/stoatchat/stoatchat/commit/9fd7128f800badbd184baf943d4f799e601201e4))
* january ip redirects & domain resolver ([#738](https://github.com/stoatchat/stoatchat/issues/738)) ([356491e](https://github.com/stoatchat/stoatchat/commit/356491e934b274f9e895df883dd63ef0b3123510))
* update message length validation to remove upper limit ([#723](https://github.com/stoatchat/stoatchat/issues/723)) ([ed4fd5e](https://github.com/stoatchat/stoatchat/commit/ed4fd5ebfe6d0ea534a0898da4afdc1f4e2cd6c5))
* use correct response for NoEffect errors ([#732](https://github.com/stoatchat/stoatchat/issues/732)) ([5378cd2](https://github.com/stoatchat/stoatchat/commit/5378cd22b4c7d85f44c31a6af0dda00941b80d5c))
## [0.12.1](https://github.com/stoatchat/stoatchat/compare/v0.12.0...v0.12.1) (2026-04-10) ## [0.12.1](https://github.com/stoatchat/stoatchat/compare/v0.12.0...v0.12.1) (2026-04-10)

5380
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace] [workspace]
resolver = "2" resolver = "3"
members = [ members = [
"crates/delta", "crates/delta",
@@ -20,24 +20,131 @@ lto = true
[workspace.dependencies] [workspace.dependencies]
# Async # Async
async-trait = "0.1.89" async-trait = "0.1.89"
tokio = { version = "1.49.0", features = ["macros", "rt"] } tokio = "1.49.0"
async-channel = "2.3.1"
futures = "0.3.32"
async-tungstenite = "0.17.0"
futures-locks = "0.7.1"
async-lock = "2.8.0"
async-recursion = "1.0.4"
tokio-util = { version = "0.7.18" }
# Error Handling # Error Handling
anyhow = "1.0.100" anyhow = "1.0.100"
thiserror = "2.0.18" thiserror = "2.0.18"
sentry = "0.31.5"
sentry-anyhow = "0.38.1"
# Other Utilities # Data Validation
uuid = { version = "1.19.0", features = ["v4"] } 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 = { version = "0.1.4", default-features = false }
fred = "8.0.1"
# Serialisation
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = { version = "1", features = ["derive"] }
strum_macros = "0.26.4"
# MongoDB
bson = { version = "2.1.0" }
mongodb = { version = "3.1.0" }
# S3
aws-config = "1.5.5"
aws-sdk-s3 = "1.46.0"
# Axum (HTTP server) # Axum (HTTP server)
axum-macros = "0.4.1" axum-macros = "0.4.1"
axum_typed_multipart = "0.12.1" axum_typed_multipart = "0.12.1"
axum = { version = "0.7.5", features = ["multipart"] } axum = "0.7.5"
tower-http = { version = "0.5.2", features = ["cors", "trace"] } 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 # Image Processing
jxl-oxide = { version = "0.12.5", features = ["image"] } jxl-oxide = "0.12.5"
image = "0.25.9" 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 # OpenTelemetry
tracing = "0.1.44" tracing = "0.1.44"
@@ -47,4 +154,52 @@ tracing-subscriber = { version = "0.3.22", features = [
opentelemetry = { version = "0.31.0", features = ["logs"] } opentelemetry = { version = "0.31.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.31.0", features = ["logs"] } opentelemetry_sdk = { version = "0.31.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.31.0", features = ["logs"] } opentelemetry-otlp = { version = "0.31.0", features = ["logs"] }
opentelemetry-appender-tracing = { version = "0.31.1" } opentelemetry-appender-tracing = "0.31.1"
# RabbitMQ
lapin = "4.7.1"
# Voice
livekit-api = "=0.4.23"
livekit-protocol = "=0.7.7"
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" }

View File

@@ -8,29 +8,20 @@ services:
# MongoDB # MongoDB
database: database:
image: mongo image: mongo
command: ["--replSet", "rs0", "--bind_ip_all"] command: mongod --replSet rs0
ports: ports:
- "27017:27017" - "27017:27017"
volumes: volumes:
- ./.data/db:/data/db - ./.data/db:/data/db
- ./scripts/mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck: healthcheck:
test: > test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]}) }" | mongosh --port 27017 --quiet
mongosh --quiet --eval "
try {
const status = rs.status();
if (status.ok === 1 && status.members[0].stateStr === 'PRIMARY') {
quit(0);
} else {
quit(1);
}
} catch (e) {
quit(1);
}"
interval: 5s interval: 5s
timeout: 5s timeout: 30s
retries: 10 start_period: 0s
start_period: 10s start_interval: 1s
retries: 30
ulimits: ulimits:
nofile: nofile:
soft: 65536 soft: 65536
@@ -38,11 +29,12 @@ services:
# MinIO # MinIO
minio: minio:
image: minio/minio image: firstfinger/minio:latest
command: server /data #command: server /data
environment: environment:
MINIO_ROOT_USER: minioautumn MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn MINIO_ROOT_PASSWORD: minioautumn
MINIO_REGION: minio
volumes: volumes:
- ./.data/minio:/data - ./.data/minio:/data
ports: ports:

View File

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

View File

@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use futures::future::join_all; use futures::future::join_all;
use redis_kiss::AsyncCommands;
use revolt_database::{ use revolt_database::{
events::client::{EventV1, ReadyPayloadFields}, events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery, util::permissions::DatabasePermissionQuery,

View File

@@ -1,10 +1,8 @@
use std::{ use std::{
collections::{HashMap, HashSet}, collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, time::Duration
sync::Arc,
time::Duration,
}; };
use async_std::sync::{Mutex, RwLock}; use tokio::sync::{Mutex, RwLock};
use lru::LruCache; use lru::LruCache;
use lru_time_cache::{LruCache as LruTimeCache, TimedEntry}; use lru_time_cache::{LruCache as LruTimeCache, TimedEntry};
use revolt_database::{Channel, Member, Server, User}; use revolt_database::{Channel, Member, Server, User};
@@ -57,7 +55,7 @@ impl Default for Cache {
members: Default::default(), members: Default::default(),
servers: Default::default(), servers: Default::default(),
seen_events: LruCache::new(20), seen_events: LruCache::new(NonZeroUsize::new(20).unwrap()),
} }
} }
} }

View File

@@ -1,6 +1,6 @@
use std::env; use std::env;
use async_std::net::TcpListener; use tokio::net::TcpListener;
use revolt_presence::clear_region; use revolt_presence::clear_region;
#[macro_use] #[macro_use]
@@ -12,7 +12,7 @@ pub mod events;
mod database; mod database;
mod websocket; mod websocket;
#[async_std::main] #[tokio::main]
async fn main() { async fn main() {
// Configure requirements for Bonfire. // Configure requirements for Bonfire.
revolt_config::configure!(events); revolt_config::configure!(events);
@@ -33,7 +33,7 @@ async fn main() {
// Start accepting new connections and spawn a client for each connection. // Start accepting new connections and spawn a client for each connection.
while let Ok((stream, addr)) = listener.accept().await { while let Ok((stream, addr)) = listener.accept().await {
async_std::task::spawn(async move { tokio::task::spawn(async move {
info!("User connected from {addr:?}"); info!("User connected from {addr:?}");
websocket::client(database::get_db(), stream, addr).await; websocket::client(database::get_db(), stream, addr).await;
info!("User disconnected from {addr:?}"); info!("User disconnected from {addr:?}");

View File

@@ -1,11 +1,10 @@
use std::{collections::HashSet, net::SocketAddr, sync::Arc}; use std::{collections::HashSet, net::SocketAddr, sync::Arc};
use async_tungstenite::WebSocketStream; use async_tungstenite::WebSocketStream;
use authifier::AuthifierEvent;
use fred::{ use fred::{
error::RedisErrorKind, error::RedisErrorKind,
interfaces::{ClientLike, EventInterface, PubsubInterface}, interfaces::{ClientLike, EventInterface, PubsubInterface},
types::RedisConfig, types::{ReconnectPolicy, RedisConfig},
}; };
use futures::{ use futures::{
channel::oneshot, channel::oneshot,
@@ -13,7 +12,7 @@ use futures::{
stream::{SplitSink, SplitStream}, stream::{SplitSink, SplitStream},
FutureExt, SinkExt, StreamExt, TryStreamExt, FutureExt, SinkExt, StreamExt, TryStreamExt,
}; };
use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI}; use redis_kiss::{get_connection, AsyncCommands, PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_config::report_internal_error; use revolt_config::report_internal_error;
use revolt_database::{ use revolt_database::{
events::{client::EventV1, server::ClientMessage}, events::{client::EventV1, server::ClientMessage},
@@ -22,19 +21,21 @@ use revolt_database::{
}; };
use revolt_presence::{create_session, delete_session}; use revolt_presence::{create_session, delete_session};
use async_std::{ use tokio::{
net::TcpStream, net::TcpStream,
sync::{Mutex, RwLock}, sync::{Mutex, RwLock},
task::spawn, task::spawn,
}; };
use tokio_util::compat::{TokioAsyncReadCompatExt, Compat};
use revolt_result::create_error; use revolt_result::create_error;
use sentry::Level; use sentry::Level;
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback}; use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange}; use crate::events::state::{State, SubscriptionStateChange};
use revolt_models::v0;
type WsReader = SplitStream<WebSocketStream<TcpStream>>; type WsReader = SplitStream<WebSocketStream<Compat<TcpStream>>>;
type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>; type WsWriter = SplitSink<WebSocketStream<Compat<TcpStream>>, async_tungstenite::tungstenite::Message>;
/// Start a new WebSocket client worker given access to the database, /// Start a new WebSocket client worker given access to the database,
/// the relevant TCP stream and the remote address of the client. /// the relevant TCP stream and the remote address of the client.
@@ -44,7 +45,7 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// e.g. wss://example.com?format=json&version=1 // e.g. wss://example.com?format=json&version=1
let (sender, receiver) = oneshot::channel(); let (sender, receiver) = oneshot::channel();
let Ok(ws) = async_tungstenite::accept_hdr_async_with_config( let Ok(ws) = async_tungstenite::accept_hdr_async_with_config(
stream, stream.compat(),
WebsocketHandshakeCallback::from(sender), WebsocketHandshakeCallback::from(sender),
None, None,
) )
@@ -128,6 +129,14 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
return; return;
} }
let slowmodes = fetch_user_slowmodes(&user_id).await.unwrap_or_default();
if !slowmodes.is_empty() {
let event = EventV1::UserSlowmodes { slowmodes };
if report_internal_error!(write.send(config.encode(&event)).await).is_err() {
return;
}
}
// Create presence session. // Create presence session.
let (first_session, session_id) = create_session(&user_id, 0).await; let (first_session, session_id) = create_session(&user_id, 0).await;
@@ -225,9 +234,9 @@ async fn listener(
.unwrap_or(REDIS_URI.to_string()); .unwrap_or(REDIS_URI.to_string());
let redis_config = RedisConfig::from_url(&url).unwrap(); let redis_config = RedisConfig::from_url(&url).unwrap();
let subscriber = match report_internal_error!( let mut builder = fred::types::Builder::from_config(redis_config);
fred::types::Builder::from_config(redis_config).build_subscriber_client() builder.set_policy(ReconnectPolicy::new_exponential(8, 100, 30_000, 2));
) { let subscriber = match report_internal_error!(builder.build_subscriber_client()) {
Ok(subscriber) => subscriber, Ok(subscriber) => subscriber,
Err(_) => return, Err(_) => return,
}; };
@@ -236,16 +245,21 @@ async fn listener(
return; return;
} }
// Let Fred automatically re-subscribe to tracked channels on reconnect.
subscriber.manage_subscriptions();
// Handle Redis connection dropping // Handle Redis connection dropping
let (clean_up_s, clean_up_r) = async_channel::bounded(1); let (clean_up_s, clean_up_r) = async_channel::bounded(1);
let clean_up_s = Arc::new(Mutex::new(clean_up_s)); let clean_up_s = Arc::new(Mutex::new(clean_up_s));
subscriber.on_error(move |err| { subscriber.on_error(move |err| {
warn!("Redis subscriber error: {:?}", err);
if let RedisErrorKind::Canceled = err.kind() { if let RedisErrorKind::Canceled = err.kind() {
let clean_up_s = clean_up_s.clone(); let clean_up_s = clean_up_s.clone();
spawn(async move { spawn(async move {
clean_up_s.lock().await.send(()).await.ok(); clean_up_s.lock().await.send(()).await.ok();
}); });
} }
// Transient errors (IO, timeout) are handled by the reconnect policy.
Ok(()) Ok(())
}); });
@@ -341,22 +355,20 @@ async fn listener(
break 'out; break 'out;
}; };
if let EventV1::Auth(auth) = &event { if let EventV1::DeleteSession { session_id, .. } = &event {
if let AuthifierEvent::DeleteSession { session_id, .. } = auth { if &state.session_id == session_id {
if &state.session_id == session_id { event = EventV1::Logout;
event = EventV1::Logout; }
} } else if let EventV1::DeleteAllSessions {
} else if let AuthifierEvent::DeleteAllSessions { exclude_session_id, ..
exclude_session_id, .. } = &event
} = auth {
{ if let Some(excluded) = exclude_session_id {
if let Some(excluded) = exclude_session_id { if &state.session_id != excluded {
if &state.session_id != excluded {
event = EventV1::Logout;
}
} else {
event = EventV1::Logout; event = EventV1::Logout;
} }
} else {
event = EventV1::Logout;
} }
} else { } else {
let should_send = state.handle_incoming_event_v1(db, &mut event).await; let should_send = state.handle_incoming_event_v1(db, &mut event).await;
@@ -523,3 +535,42 @@ async fn worker(
} }
} }
} }
async fn fetch_user_slowmodes(user_id: &str) -> Option<Vec<v0::ChannelSlowmode>> {
let mut conn = get_connection().await.ok()?.into_inner();
let idx_key = format!("slowmode_idx:{}", user_id);
let channel_ids: Vec<String> = conn.smembers(&idx_key).await.unwrap_or_default();
if channel_ids.is_empty() {
return Some(vec![]);
}
// Bulk fetch all TTLs in one round trip
let mut pipe = redis_kiss::redis::pipe();
for channel_id in &channel_ids {
pipe.ttl(format!("slowmode:{}:{}", user_id, channel_id));
}
let ttls: Vec<i64> = pipe.query_async(&mut conn).await.unwrap_or_default();
// Partition into alive/expired in one pass
let mut slowmodes = vec![];
let mut expired = vec![];
for (channel_id, ttl) in channel_ids.iter().zip(ttls.iter()) {
if *ttl > 0 {
slowmodes.push(v0::ChannelSlowmode {
channel_id: channel_id.clone(),
duration: *ttl as u64,
retry_after: *ttl as u64,
});
} else {
expired.push(channel_id.as_str());
}
}
// Bulk remove all expired members in one SREM call
if !expired.is_empty() {
conn.srem::<_, _, ()>(&idx_key, expired).await.ok();
}
Some(slowmodes)
}

View File

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

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-config" name = "revolt-config"
version = "0.12.1" version = "0.13.7"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -13,29 +13,28 @@ repository = "https://github.com/stoatchat/stoatchat"
anyhow = ["dep:sentry-anyhow"] anyhow = ["dep:sentry-anyhow"]
report-macros = ["revolt-result"] report-macros = ["revolt-result"]
sentry = ["dep:sentry"] sentry = ["dep:sentry"]
test = ["async-std"] test = ["tokio"]
default = ["test", "sentry"] default = ["sentry"]
[dependencies] [dependencies]
# Utility # Utility
config = "0.13.3" config = { workspace = true }
cached = "0.44.0" cached = { workspace = true }
once_cell = "1.18.0"
# Serde # Serde
serde = { version = "1", features = ["derive"] } serde = { workspace = true }
# Async # Async
futures-locks = "0.7.1" futures-locks = { workspace = true }
async-std = { version = "1.8.0", features = ["attributes"], optional = true } tokio = { workspace = true, optional = true }
# Logging # Logging
log = "0.4.14" log = { workspace = true }
pretty_env_logger = "0.4.0" pretty_env_logger = { workspace = true }
# Sentry # Sentry
sentry = { version = "0.31.5", optional = true } sentry = { workspace = true, optional = true }
sentry-anyhow = { version = "0.38.1", optional = true } sentry-anyhow = { workspace = true, optional = true }
# Core # Core
revolt-result = { version = "0.12.1", path = "../result", optional = true } revolt-result = { workspace = true, optional = true }

View File

@@ -1,3 +1,5 @@
environment = "test"
[database] [database]
mongodb = "mongodb://localhost?directConnection=true&replicaSet=rs0" mongodb = "mongodb://localhost?directConnection=true&replicaSet=rs0"
redis = "redis://localhost/" redis = "redis://localhost/"
@@ -10,3 +12,12 @@ password = "rabbitpass"
[features] [features]
webhooks_enabled = true 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,5 +1,6 @@
production = false production = false
disable_events_dont_use = false disable_events_dont_use = false
environment = "dev"
[database] [database]
# MongoDB connection URL # MongoDB connection URL
@@ -30,6 +31,10 @@ host = "rabbit"
port = 5672 port = 5672
username = "rabbituser" username = "rabbituser"
password = "rabbitpass" password = "rabbitpass"
default_exchange = "revolt.default"
[rabbit.queues]
acks = "internal.ack"
[api] [api]
@@ -49,6 +54,10 @@ from_address = "noreply@example.com"
# port = 587 # port = 587
# use_tls = true # 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] [api.security]
# Authifier Shield API key # Authifier Shield API key
@@ -67,6 +76,10 @@ tenor_key = ""
hcaptcha_key = "" hcaptcha_key = ""
hcaptcha_sitekey = "" hcaptcha_sitekey = ""
[api.security.shield]
host = ""
key = ""
[api.workers] [api.workers]
# Maximum concurrent connections (to proxy server) # Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50 max_concurrent_connections = 50
@@ -78,6 +91,8 @@ call_ring_duration = 30
[api.livekit.nodes] [api.livekit.nodes]
[api.users] [api.users]
# Minimum allowed length of usernames
min_username_length = 2
[pushd] [pushd]
# this changes the names of the queues to not overlap # this changes the names of the queues to not overlap
@@ -130,6 +145,8 @@ pkcs8 = ""
key_id = "" key_id = ""
team_id = "" team_id = ""
[january]
blocked_domains = []
[files] [files]
# Encryption key for stored files # Encryption key for stored files
@@ -313,6 +330,12 @@ emojis = 500_000
# default: 5 # default: 5
process_message_delay_limit = 5 process_message_delay_limit = 5
[features.legal_links]
# URLs for legal documents
terms_of_service = ""
privacy_policy = ""
guidelines = ""
[sentry] [sentry]
# Configuration for Sentry error reporting # Configuration for Sentry error reporting
api = "" api = ""

View File

@@ -1,9 +1,10 @@
use std::{collections::HashMap, path::Path}; #[cfg(feature = "test")]
use std::sync::OnceLock;
use std::{collections::HashMap, path::Path, sync::LazyLock};
use cached::proc_macro::cached; use cached::proc_macro::cached;
use config::{Config, Environment, File, FileFormat}; use config::{Config, Environment, File, FileFormat};
use futures_locks::RwLock; use futures_locks::RwLock;
use once_cell::sync::Lazy;
use serde::Deserialize; use serde::Deserialize;
#[cfg(feature = "sentry")] #[cfg(feature = "sentry")]
@@ -66,13 +67,28 @@ static CONFIG_SEARCH_PATHS: [&str; 3] = [
static TEST_OVERRIDE_PATH: &str = "Revolt.test-overrides.toml"; static TEST_OVERRIDE_PATH: &str = "Revolt.test-overrides.toml";
/// Configuration builder /// Configuration builder
static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| { static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
RwLock::new({ RwLock::new({
let mut builder = Config::builder().add_source(File::from_str( let mut builder = Config::builder().add_source(File::from_str(
include_str!("../Revolt.toml"), include_str!("../Revolt.toml"),
FileFormat::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() { if std::env::var("TEST_DB").is_ok() {
builder = builder.add_source(File::from_str( builder = builder.add_source(File::from_str(
include_str!("../Revolt.test.toml"), include_str!("../Revolt.test.toml"),
@@ -94,21 +110,6 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
} }
} }
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();
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__")); builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap() builder.build().unwrap()
@@ -122,12 +123,19 @@ pub struct Database {
pub redis_pubsub: Option<String>, pub redis_pubsub: Option<String>,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Rabbit { pub struct Rabbit {
pub host: String, pub host: String,
pub port: u16, pub port: u16,
pub username: String, pub username: String,
pub password: String, pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -155,6 +163,18 @@ pub struct ApiSmtp {
pub port: Option<i32>, pub port: Option<i32>,
pub use_tls: Option<bool>, pub use_tls: Option<bool>,
pub use_starttls: 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)] #[derive(Deserialize, Debug, Clone)]
@@ -194,9 +214,15 @@ pub struct ApiSecurityCaptcha {
pub hcaptcha_sitekey: String, pub hcaptcha_sitekey: String,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityShield {
pub host: String,
pub key: String,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurity { pub struct ApiSecurity {
pub authifier_shield_key: String, pub shield: ApiSecurityShield,
pub voso_legacy_token: String, pub voso_legacy_token: String,
pub captcha: ApiSecurityCaptcha, pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool, pub trust_cloudflare: bool,
@@ -231,6 +257,7 @@ pub struct LiveKitNode {
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers { pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>, pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -301,6 +328,11 @@ impl Pushd {
} }
} }
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit { pub struct FilesLimit {
pub min_file_size: usize, pub min_file_size: usize,
@@ -376,6 +408,16 @@ pub struct FeaturesLimitsCollection {
pub roles: HashMap<String, FeaturesLimits>, pub roles: HashMap<String, FeaturesLimits>,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced { pub struct FeaturesAdvanced {
#[serde(default)] #[serde(default)]
@@ -393,6 +435,7 @@ impl Default for FeaturesAdvanced {
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Features { pub struct Features {
pub limits: FeaturesLimitsCollection, pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool, pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool, pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool, pub mass_mentions_enabled: bool,
@@ -433,10 +476,12 @@ pub struct Settings {
pub hosts: Hosts, pub hosts: Hosts,
pub api: Api, pub api: Api,
pub pushd: Pushd, pub pushd: Pushd,
pub january: January,
pub files: Files, pub files: Files,
pub features: Features, pub features: Features,
pub sentry: Sentry, pub sentry: Sentry,
pub production: bool, pub production: bool,
pub environment: String,
pub disable_events_dont_use: bool, pub disable_events_dont_use: bool,
pub elasticsearch: Elasticsearch, pub elasticsearch: Elasticsearch,
} }
@@ -464,8 +509,7 @@ pub async fn read() -> Config {
CONFIG_BUILDER.read().await.clone() CONFIG_BUILDER.read().await.clone()
} }
#[cached(time = 30)] pub async fn config_no_cache() -> Settings {
pub async fn config() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap(); let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library // inject REDIS_URI for redis-kiss library
@@ -483,6 +527,34 @@ pub async fn config() -> Settings {
config 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 /// Configure logging and common Rust variables
#[cfg(feature = "sentry")] #[cfg(feature = "sentry")]
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> { pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {
@@ -528,7 +600,7 @@ macro_rules! configure {
mod tests { mod tests {
use crate::init; use crate::init;
#[async_std::test] #[tokio::test]
async fn it_works() { async fn it_works() {
init().await; init().await;
} }

View File

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

@@ -0,0 +1,555 @@
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,67 +1,85 @@
use std::collections::HashSet; use std::collections::HashSet;
use std::sync::Arc;
use crate::events::rabbit::*; use crate::{Message, events::rabbit::*};
use crate::{Message, User}; use crate::User;
use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments}; use lapin::{
use amqprs::connection::OpenConnectionArguments; options::BasicPublishOptions,
use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError}; protocol::basic::AMQPProperties,
use amqprs::{BasicProperties, FieldTable}; types::{AMQPValue, FieldTable},
Channel, Connection, ConnectionProperties, Error as AMQPError,
};
use revolt_models::v0::PushNotification; use revolt_models::v0::PushNotification;
use revolt_presence::filter_online; use revolt_presence::filter_online;
use revolt_result::Result;
use serde_json::to_string; use serde_json::to_string;
#[derive(Clone)] #[derive(Clone)]
pub struct AMQP { pub struct AMQP {
friend_request_accepted: Arc<Channel>,
friend_request_received: Arc<Channel>,
generic_message: Arc<Channel>,
message_sent: Arc<Channel>,
mass_mention_message_sent: Arc<Channel>,
ack_notification_message: Arc<Channel>,
dm_call_updated: Arc<Channel>,
process_ack: Arc<Channel>,
message_search: Arc<Channel>,
edit_message_search: Arc<Channel>,
delete_message_search: Arc<Channel>,
delete_channel_search: Arc<Channel>,
#[allow(unused)] #[allow(unused)]
connection: Connection, connection: Arc<Connection>,
channel: Channel,
} }
impl AMQP { impl AMQP {
pub fn new(connection: Connection, channel: Channel) -> AMQP { pub async fn new(connection: Arc<Connection>) -> Self {
AMQP { 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,
message_search: Self::create_channel(&connection).await,
edit_message_search: Self::create_channel(&connection).await,
delete_message_search: Self::create_channel(&connection).await,
delete_channel_search: Self::create_channel(&connection).await,
connection, connection,
channel,
} }
} }
pub async fn new_auto() -> AMQP { pub async fn new_auto() -> Self {
let config = revolt_config::config().await; let config = revolt_config::config().await;
let connection = Connection::open(&OpenConnectionArguments::new( let connection = Arc::new(
&config.rabbit.host, Connection::connect(
config.rabbit.port, &format!(
&config.rabbit.username, "amqp://{}:{}@{}:{}",
&config.rabbit.password, &config.rabbit.username,
)) &config.rabbit.password,
.await &config.rabbit.host,
.expect("Failed to connect to RabbitMQ"); &config.rabbit.port,
),
let channel = connection ConnectionProperties::default(),
.open_channel(None)
.await
.expect("Failed to open RabbitMQ channel");
channel
.exchange_declare(
ExchangeDeclareArguments::new(&config.pushd.exchange, "direct")
.durable(true)
.finish(),
) )
.await .await
.expect("Failed to declare exchange"); .expect("Failed to connect to RabbitMQ"),
);
channel Self::new(connection).await
.exchange_declare( }
ExchangeDeclareArguments::new(&config.elasticsearch.exchange, "direct")
.durable(true)
.finish(),
)
.await
.expect("Failed to declare exchange");
AMQP::new(connection, channel) async fn create_channel(connection: &Connection) -> Arc<Channel> {
Arc::new(
connection
.create_channel()
.await
.expect("Failed to create channel"),
)
} }
pub async fn friend_request_accepted( pub async fn friend_request_accepted(
@@ -81,19 +99,20 @@ impl AMQP {
config.pushd.get_fr_accepted_routing_key(), config.pushd.get_fr_accepted_routing_key(),
payload payload
); );
self.channel
self.friend_request_accepted
.basic_publish( .basic_publish(
BasicProperties::default() config.pushd.exchange.clone().into(),
.with_content_type("application/json") config.pushd.get_fr_accepted_routing_key().into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.pushd.exchange, .with_delivery_mode(2),
&config.pushd.get_fr_accepted_routing_key(),
),
) )
.await .await?;
Ok(())
} }
pub async fn friend_request_received( pub async fn friend_request_received(
@@ -114,19 +133,19 @@ impl AMQP {
payload payload
); );
self.channel self.friend_request_received
.basic_publish( .basic_publish(
BasicProperties::default() config.pushd.exchange.clone().into(),
.with_content_type("application/json") config.pushd.get_fr_received_routing_key().into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.pushd.exchange, .with_delivery_mode(2),
&config.pushd.get_fr_received_routing_key(),
),
) )
.await .await?;
Ok(())
} }
pub async fn generic_message( pub async fn generic_message(
@@ -151,19 +170,19 @@ impl AMQP {
payload payload
); );
self.channel self.generic_message
.basic_publish( .basic_publish(
BasicProperties::default() config.pushd.exchange.clone().into(),
.with_content_type("application/json") config.pushd.get_generic_routing_key().into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.pushd.exchange, .with_delivery_mode(2),
&config.pushd.get_generic_routing_key(),
),
) )
.await .await?;
Ok(())
} }
pub async fn message_sent( pub async fn message_sent(
@@ -194,19 +213,19 @@ impl AMQP {
payload payload
); );
self.channel self.message_sent
.basic_publish( .basic_publish(
BasicProperties::default() config.pushd.exchange.clone().into(),
.with_content_type("application/json") config.pushd.get_message_routing_key().into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.pushd.exchange, .with_delivery_mode(2),
&config.pushd.get_message_routing_key(),
),
) )
.await .await?;
Ok(())
} }
pub async fn mass_mention_message_sent( pub async fn mass_mention_message_sent(
@@ -229,19 +248,24 @@ impl AMQP {
routing_key, payload routing_key, payload
); );
self.channel self.mass_mention_message_sent
.basic_publish( .basic_publish(
BasicProperties::default() config.pushd.exchange.clone().into(),
.with_content_type("application/json") routing_key.into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new(&config.pushd.exchange, routing_key.as_str()), .with_content_type("application/json".into())
.with_delivery_mode(2),
) )
.await .await?;
Ok(())
} }
pub async fn ack_message( /// # Sends an ack to pushd to update badges on iPhones.
/// Not to be confused with the process_ack function, which handles sending all acks to crond for processing.
pub async fn ack_notification_message(
&self, &self,
user_id: String, user_id: String,
channel_id: String, channel_id: String,
@@ -261,23 +285,25 @@ impl AMQP {
config.pushd.ack_queue, payload config.pushd.ack_queue, payload
); );
let mut headers = FieldTable::new(); let mut headers = FieldTable::default();
headers.insert( headers.insert(
"x-deduplication-header".try_into().unwrap(), "x-deduplication-header".into(),
format!("{}-{}", &user_id, &channel_id).into(), AMQPValue::LongString(format!("{}-{}", &user_id, &channel_id).into()),
); );
self.channel self.ack_notification_message
.basic_publish( .basic_publish(
BasicProperties::default() config.pushd.exchange.clone().into(),
.with_content_type("application/json") config.pushd.ack_queue.into(),
.with_persistence(true) BasicPublishOptions::default(),
//.with_headers(headers) payload.as_bytes(),
.finish(), AMQPProperties::default()
payload.into(), .with_content_type("application/json".into())
BasicPublishArguments::new(&config.pushd.exchange, &config.pushd.ack_queue), .with_delivery_mode(2),
) )
.await .await?;
Ok(())
} }
/// # DM Call Update /// # DM Call Update
@@ -311,19 +337,55 @@ impl AMQP {
payload payload
); );
self.channel self.dm_call_updated
.basic_publish( .basic_publish(
BasicProperties::default() config.pushd.exchange.clone().into(),
.with_content_type("application/json") config.pushd.get_dm_call_routing_key().into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.pushd.exchange, .with_delivery_mode(2),
&config.pushd.get_dm_call_routing_key(),
),
) )
.await .await?;
Ok(())
}
/// # Send an ack to crond for processing
pub async fn process_ack(
&self,
user_id: &str,
channel_id: Option<&str>,
server_id: Option<&str>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = AckEventPayload {
user_id: user_id.to_string(),
channel_id: channel_id.map(|value| value.to_string()),
server_id: server_id.map(|value| value.to_string()),
};
let payload = to_string(&payload).unwrap();
info!(
"Sending ack processor event on exchange {}, channel {}: {}",
config.rabbit.default_exchange, config.rabbit.queues.acks, payload
);
self.process_ack
.basic_publish(
config.rabbit.default_exchange.clone().into(),
config.rabbit.queues.acks.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
} }
pub async fn new_message_search( pub async fn new_message_search(
@@ -340,19 +402,19 @@ impl AMQP {
config.elasticsearch.message_queue, payload config.elasticsearch.message_queue, payload
); );
self.channel self.message_search
.basic_publish( .basic_publish(
BasicProperties::default() config.elasticsearch.exchange.clone().into(),
.with_content_type("application/json") config.elasticsearch.message_queue.into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.elasticsearch.exchange, .with_delivery_mode(2),
&config.elasticsearch.message_queue,
),
) )
.await .await?;
Ok(())
} }
pub async fn edit_message_search( pub async fn edit_message_search(
@@ -369,19 +431,19 @@ impl AMQP {
config.elasticsearch.message_edit_queue, payload config.elasticsearch.message_edit_queue, payload
); );
self.channel self.edit_message_search
.basic_publish( .basic_publish(
BasicProperties::default() config.elasticsearch.exchange.clone().into(),
.with_content_type("application/json") config.elasticsearch.message_edit_queue.into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.elasticsearch.exchange, .with_delivery_mode(2),
&config.elasticsearch.message_edit_queue,
),
) )
.await .await?;
Ok(())
} }
pub async fn delete_message_search(&self, message_id: String) -> Result<(), AMQPError> { pub async fn delete_message_search(&self, message_id: String) -> Result<(), AMQPError> {
@@ -394,19 +456,19 @@ impl AMQP {
config.elasticsearch.message_delete_queue, payload config.elasticsearch.message_delete_queue, payload
); );
self.channel self.delete_message_search
.basic_publish( .basic_publish(
BasicProperties::default() config.elasticsearch.exchange.clone().into(),
.with_content_type("application/json") config.elasticsearch.message_delete_queue.into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.elasticsearch.exchange, .with_delivery_mode(2),
&config.elasticsearch.message_delete_queue,
),
) )
.await .await?;
Ok(())
} }
pub async fn delete_channel_search(&self, channel_id: String) -> Result<(), AMQPError> { pub async fn delete_channel_search(&self, channel_id: String) -> Result<(), AMQPError> {
@@ -419,18 +481,18 @@ impl AMQP {
config.elasticsearch.channel_delete_queue, payload config.elasticsearch.channel_delete_queue, payload
); );
self.channel self.delete_channel_search
.basic_publish( .basic_publish(
BasicProperties::default() config.elasticsearch.exchange.clone().into(),
.with_content_type("application/json") config.elasticsearch.channel_delete_queue.into(),
.with_persistence(true) BasicPublishOptions::default(),
.finish(), payload.as_bytes(),
payload.into(), AMQPProperties::default()
BasicPublishArguments::new( .with_content_type("application/json".into())
&config.elasticsearch.exchange, .with_delivery_mode(2),
&config.elasticsearch.channel_delete_queue,
),
) )
.await .await?;
Ok(())
} }
} }

View File

@@ -2,16 +2,7 @@
mod mongodb; mod mongodb;
mod reference; mod reference;
use authifier::config::Captcha;
use authifier::config::EmailVerificationConfig;
use authifier::config::PasswordScanning;
use authifier::config::ResolveIp;
use authifier::config::SMTPSettings;
use authifier::config::Shield;
use authifier::config::Template;
use authifier::config::Templates;
use authifier::config::EmailExpiryConfig;
use authifier::Authifier;
use rand::Rng; use rand::Rng;
use revolt_config::config; use revolt_config::config;
@@ -112,143 +103,3 @@ impl DatabaseInfo {
} }
} }
} }
impl Database {
/// Create an Authifier reference
pub async fn to_authifier(self) -> Authifier {
let config = config().await;
let mut auth_config = authifier::Config {
password_scanning: if config.api.security.easypwned.is_empty() {
Default::default()
} else {
PasswordScanning::EasyPwned {
endpoint: config.api.security.easypwned,
}
},
email_verification: if !config.api.smtp.host.is_empty() {
EmailVerificationConfig::Enabled {
smtp: SMTPSettings {
from: config.api.smtp.from_address,
host: config.api.smtp.host,
username: config.api.smtp.username,
password: config.api.smtp.password,
reply_to: Some(
config
.api
.smtp
.reply_to
.unwrap_or("support@stoat.chat".into()),
),
port: config.api.smtp.port,
use_tls: config.api.smtp.use_tls,
use_starttls: config.api.smtp.use_starttls,
},
expiry: EmailExpiryConfig {
expire_verification: 3600 * 24 * 7,
expire_password_reset: 3600 * 24,
expire_account_deletion: 3600 * 24,
},
templates: if config.production {
Templates {
verify: Template {
title: "Verify your Stoat account.".into(),
text: include_str!("../../templates/verify.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: Some(include_str!("../../templates/verify.html").into()),
},
reset: Template {
title: "Reset your Stoat password.".into(),
text: include_str!("../../templates/reset.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset.html").into()),
},
reset_existing: Template {
title: "You already have a Stoat account, reset your password."
.into(),
text: include_str!("../../templates/reset-existing.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(
include_str!("../../templates/reset-existing.html").into(),
),
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: Some(include_str!("../../templates/deletion.html").into()),
},
welcome: None,
}
} else {
Templates {
verify: Template {
title: "Verify your account.".into(),
text: include_str!("../../templates/verify.whitelabel.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: None,
},
reset: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
reset_existing: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.whitelabel.txt")
.into(),
url: format!("{}/delete/", config.hosts.app),
html: None,
},
welcome: None,
}
},
}
} else {
EmailVerificationConfig::Disabled
},
..Default::default()
};
auth_config.invite_only = config.api.registration.invite_only;
if !config.api.security.captcha.hcaptcha_key.is_empty() {
auth_config.captcha = Captcha::HCaptcha {
secret: config.api.security.captcha.hcaptcha_key,
};
}
if !config.api.security.authifier_shield_key.is_empty() {
auth_config.shield = Shield::Enabled {
api_key: config.api.security.authifier_shield_key,
strict: false,
};
}
if config.api.security.trust_cloudflare {
auth_config.resolve_ip = ResolveIp::Cloudflare;
}
Authifier {
database: match self {
Database::Reference(_) => Default::default(),
#[cfg(feature = "mongodb")]
Database::MongoDb(MongoDb(client, _)) => authifier::Database::MongoDb(
authifier::database::MongoDb(client.database("revolt")),
),
},
config: auth_config,
#[cfg(feature = "tasks")]
event_channel: Some(crate::tasks::authifier_relay::sender()),
#[cfg(not(feature = "tasks"))]
event_channel: None,
}
}
}

View File

@@ -5,7 +5,7 @@ use futures::lock::Mutex;
use crate::{ use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member, Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot, MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot,
User, UserSettings, Webhook, User, UserSettings, Webhook, Account, AccountInvite, Session, MFATicket
}; };
database_derived!( database_derived!(
@@ -30,5 +30,9 @@ database_derived!(
pub servers: Arc<Mutex<HashMap<String, Server>>>, pub servers: Arc<Mutex<HashMap<String, Server>>>,
pub safety_reports: Arc<Mutex<HashMap<String, Report>>>, pub safety_reports: Arc<Mutex<HashMap<String, Report>>>,
pub safety_snapshots: Arc<Mutex<HashMap<String, Snapshot>>>, 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,12 +1,16 @@
use authifier::AuthifierEvent;
use revolt_result::Error; use revolt_result::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use revolt_models::v0::{ use revolt_models::v0::{
AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook AppendMessage, Channel, 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::Database; use crate::{Account, Database, Session};
/// Ping Packet /// Ping Packet
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
@@ -51,9 +55,13 @@ impl Default for ReadyPayloadFields {
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum EventV1 { pub enum EventV1 {
/// Multiple events /// Multiple events
Bulk { v: Vec<EventV1> }, Bulk {
v: Vec<EventV1>,
},
/// Error event /// Error event
Error { data: Error }, Error {
data: Error,
},
/// Successfully authenticated /// Successfully authenticated
Authenticated, Authenticated,
@@ -84,7 +92,9 @@ pub enum EventV1 {
}, },
/// Ping response /// Ping response
Pong { data: Ping }, Pong {
data: Ping,
},
/// New message /// New message
Message(Message), Message(Message),
@@ -105,7 +115,10 @@ pub enum EventV1 {
}, },
/// Delete message /// Delete message
MessageDelete { id: String, channel: String }, MessageDelete {
id: String,
channel: String,
},
/// New reaction to a message /// New reaction to a message
MessageReact { MessageReact {
@@ -131,7 +144,10 @@ pub enum EventV1 {
}, },
/// Bulk delete messages /// Bulk delete messages
BulkMessageDelete { channel: String, ids: Vec<String> }, BulkMessageDelete {
channel: String,
ids: Vec<String>,
},
/// New server /// New server
ServerCreate { ServerCreate {
@@ -139,7 +155,7 @@ pub enum EventV1 {
server: Server, server: Server,
channels: Vec<Channel>, channels: Vec<Channel>,
emojis: Vec<Emoji>, emojis: Vec<Emoji>,
voice_states: Vec<ChannelVoiceState> voice_states: Vec<ChannelVoiceState>,
}, },
/// Update existing server /// Update existing server
@@ -151,7 +167,9 @@ pub enum EventV1 {
}, },
/// Delete server /// Delete server
ServerDelete { id: String }, ServerDelete {
id: String,
},
/// Update existing server member /// Update existing server member
ServerMemberUpdate { ServerMemberUpdate {
@@ -187,10 +205,16 @@ pub enum EventV1 {
}, },
/// Server role deleted /// Server role deleted
ServerRoleDelete { id: String, role_id: String }, ServerRoleDelete {
id: String,
role_id: String,
},
/// Server roles ranks updated /// Server roles ranks updated
ServerRoleRanksUpdate { id: String, ranks: Vec<String> }, ServerRoleRanksUpdate {
id: String,
ranks: Vec<String>,
},
/// Update existing user /// Update existing user
UserUpdate { UserUpdate {
@@ -202,9 +226,15 @@ pub enum EventV1 {
}, },
/// Relationship with another user changed /// Relationship with another user changed
UserRelationship { id: String, user: User }, UserRelationship {
id: String,
user: User,
},
/// Settings updated remotely /// Settings updated remotely
UserSettingsUpdate { id: String, update: UserSettings }, UserSettingsUpdate {
id: String,
update: UserSettings,
},
/// User has been platform banned or deleted their account /// User has been platform banned or deleted their account
/// ///
@@ -215,12 +245,23 @@ pub enum EventV1 {
/// - Server Memberships /// - Server Memberships
/// ///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear. /// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe { user_id: String, flags: i32 }, UserPlatformWipe {
user_id: String,
flags: i32,
},
/// New emoji /// New emoji
EmojiCreate(Emoji), EmojiCreate(Emoji),
/// Update existing emoji
EmojiUpdate {
id: String,
data: PartialEmoji,
},
/// Delete emoji /// Delete emoji
EmojiDelete { id: String }, EmojiDelete {
id: String,
},
/// New report /// New report
ReportCreate(Report), ReportCreate(Report),
@@ -236,19 +277,33 @@ pub enum EventV1 {
}, },
/// Delete channel /// Delete channel
ChannelDelete { id: String }, ChannelDelete {
id: String,
},
/// User joins a group /// User joins a group
ChannelGroupJoin { id: String, user: String }, ChannelGroupJoin {
id: String,
user: String,
},
/// User leaves a group /// User leaves a group
ChannelGroupLeave { id: String, user: String }, ChannelGroupLeave {
id: String,
user: String,
},
/// User started typing in a channel /// User started typing in a channel
ChannelStartTyping { id: String, user: String }, ChannelStartTyping {
id: String,
user: String,
},
/// User stopped typing in a channel /// User stopped typing in a channel
ChannelStopTyping { id: String, user: String }, ChannelStopTyping {
id: String,
user: String,
},
/// User acknowledged message in channel /// User acknowledged message in channel
ChannelAck { ChannelAck {
@@ -268,10 +323,25 @@ pub enum EventV1 {
}, },
/// Delete webhook /// Delete webhook
WebhookDelete { id: String }, WebhookDelete {
id: String,
},
/// Auth events /// Auth events
Auth(AuthifierEvent), CreateAccount {
account: Account,
},
CreateSession {
session: Session,
},
DeleteSession {
user_id: String,
session_id: String,
},
DeleteAllSessions {
user_id: String,
exclude_session_id: Option<String>,
},
/// Voice events /// Voice events
VoiceChannelJoin { VoiceChannelJoin {
@@ -286,7 +356,7 @@ pub enum EventV1 {
user: String, user: String,
from: String, from: String,
to: String, to: String,
state: UserVoiceState state: UserVoiceState,
}, },
UserVoiceStateUpdate { UserVoiceStateUpdate {
id: String, id: String,
@@ -298,7 +368,11 @@ pub enum EventV1 {
from: String, from: String,
to: String, to: String,
token: String, token: String,
} },
/// User's active slowmodes
UserSlowmodes {
slowmodes: Vec<ChannelSlowmode>,
},
} }
impl EventV1 { impl EventV1 {

View File

@@ -100,3 +100,11 @@ pub struct MessageEditPayload {
pub message: Message, pub message: Message,
pub user: Option<User>, pub user: Option<User>,
} }
/// 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

@@ -25,8 +25,8 @@ pub use mongodb;
#[macro_use] #[macro_use]
extern crate bson; extern crate bson;
#[cfg(not(feature = "async-std-runtime"))] #[cfg(not(feature = "tokio-runtime"))]
compile_error!("async-std-runtime feature must be enabled."); compile_error!("tokio-runtime feature must be enabled.");
#[macro_export] #[macro_export]
#[cfg(debug_assertions)] #[cfg(debug_assertions)]

View File

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

View File

@@ -0,0 +1,25 @@
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

@@ -0,0 +1,16 @@
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

@@ -0,0 +1,31 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,22 @@
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

@@ -0,0 +1,11 @@
#[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

@@ -0,0 +1,656 @@
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

@@ -0,0 +1,34 @@
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

@@ -0,0 +1,118 @@
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

@@ -0,0 +1,99 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,32 @@
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

@@ -11,7 +11,7 @@ auto_derived!(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
#[async_std::test] #[tokio::test]
async fn migrate() { async fn migrate() {
database_test!(|db| async move { database_test!(|db| async move {
// Initialise the database // Initialise the database

View File

@@ -98,6 +98,18 @@ pub async fn create_database(db: &MongoDb) {
.await .await
.expect("Failed to create pubsub collection."); .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! { db.run_command(doc! {
"createIndexes": "users", "createIndexes": "users",
"indexes": [ "indexes": [
@@ -263,5 +275,89 @@ pub async fn create_database(db: &MongoDb) {
.await .await
.expect("Failed to create ratelimit_events index."); .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."); info!("Created database.");
} }

View File

@@ -17,6 +17,7 @@ use iso8601_timestamp::Timestamp;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use revolt_permissions::{ChannelPermission, DEFAULT_WEBHOOK_PERMISSIONS}; use revolt_permissions::{ChannelPermission, DEFAULT_WEBHOOK_PERMISSIONS};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use ulid::Ulid;
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
@@ -25,7 +26,7 @@ struct MigrationInfo {
revision: i32, revision: i32,
} }
pub const LATEST_REVISION: i32 = 50; // MUST BE +1 to last migration pub const LATEST_REVISION: i32 = 51; // MUST BE +1 to last migration
pub async fn migrate_database(db: &MongoDb) { pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations"); let migrations = db.col::<Document>("migrations");
@@ -451,7 +452,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
warn!("This is a destructive operation and will wipe existing permission data (excl. defaults for SendMessage)."); warn!("This is a destructive operation and will wipe existing permission data (excl. defaults for SendMessage).");
warn!("Taking a backup is advised."); warn!("Taking a backup is advised.");
warn!("Continuing in 10 seconds..."); warn!("Continuing in 10 seconds...");
async_std::task::sleep(Duration::from_secs(10)).await; tokio::time::sleep(Duration::from_secs(10)).await;
let servers = db.col::<Document>("servers"); let servers = db.col::<Document>("servers");
let mut cursor = servers.find(doc! {}).await.unwrap(); let mut cursor = servers.find(doc! {}).await.unwrap();
@@ -573,20 +574,145 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
if revision <= 15 { if revision <= 15 {
info!("Running migration [revision 15 / 04-06-2022]: Migrate Authifier to latest version."); info!("Running migration [revision 15 / 04-06-2022]: Migrate Authifier to latest version.");
let db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db())); if !db
db.run_migration(authifier::Migration::M2022_06_03EnsureUpToSpec) .db()
.collection::<Document>("mfa_tickets")
.list_index_names()
.await .await
.unwrap(); .unwrap_or_default()
.contains(&"token".to_owned())
{
// Make sure all collections exist
let list = db.db().list_collection_names().await.unwrap();
let collections = ["accounts", "sessions", "invites", "mfa_tickets"];
for name in collections {
if !list.contains(&name.to_string()) {
db.db().create_collection(name).await.unwrap();
}
}
// Setup index for `accounts`
let col = db.db().collection::<Document>("accounts");
col.drop_indexes().await.unwrap();
db.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"
}
]
})
.await
.unwrap();
// Setup index for `sessions`
let col = db.db().collection::<Document>("sessions");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "sessions",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
},
{
"key": {
"user_id": 1
},
"name": "user_id"
}
]
})
.await
.unwrap();
// Setup index for `mfa_tickets`
let col = db.db().collection::<Document>("mfa_tickets");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "mfa_tickets",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
}
]
})
.await
.unwrap();
}
} }
if revision <= 16 { if revision <= 16 {
info!("Running migration [revision 16 / 07-07-2022]: Add `emojis` collection and Authifier migration."); info!("Running migration [revision 16 / 07-07-2022]: Add `emojis` collection and Authifier migration.");
let authifier_db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db())); if !db
authifier_db .db()
.run_migration(authifier::Migration::M2022_06_09AddIndexForDeletion) .collection::<Document>("accounts")
.list_index_names()
.await .await
.unwrap(); .expect("list of index names")
.contains(&"account_deletion".to_owned())
{
db.db()
.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"deletion.token": 1
},
"name": "account_deletion"
}
]
})
.await
.unwrap();
}
db.db() db.db()
.create_collection("emojis") .create_collection("emojis")
@@ -1085,7 +1211,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
enum Channel { enum Channel {
Group { owner: String }, Group { owner: String },
TextChannel { server: String }, TextChannel { server: String },
VoiceChannel { server: String } VoiceChannel { server: String },
} }
let webhooks = db let webhooks = db
@@ -1099,7 +1225,12 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.await; .await;
for webhook in webhooks { for webhook in webhooks {
match db.col::<Channel>("channels").find_one(doc! { "_id": &webhook.channel_id }).await.unwrap() { match db
.col::<Channel>("channels")
.find_one(doc! { "_id": &webhook.channel_id })
.await
.unwrap()
{
Some(channel) => { Some(channel) => {
let creator_id = match channel { let creator_id = match channel {
Channel::Group { owner, .. } => owner, Channel::Group { owner, .. } => owner,
@@ -1141,10 +1272,51 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
"Running migration [revision 32 / 12-05-2025]: (Authifier) Add last_seen to sessions." "Running migration [revision 32 / 12-05-2025]: (Authifier) Add last_seen to sessions."
); );
let db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db())); loop {
db.run_migration(authifier::Migration::M2025_02_20AddLastSeenToSession) #[derive(Deserialize)]
.await struct SessionId {
.unwrap(); _id: String,
}
let sessions: Vec<SessionId> = db
.db()
.collection("sessions")
.find(doc! {
"$or": [
{ "last_seen": { "$exists": false } },
{ "last_seen": "1970-01-01T00:00:00.000Z" }
]
})
.limit(50_000) // about 400 batches for 2 million
.await
.expect("Failed to create cursor for sessions!")
.map(|doc| doc.expect("id and username"))
.collect()
.await;
if sessions.is_empty() {
break;
}
for session in sessions {
let timestamp = iso8601_timestamp::Timestamp::from(Ulid::from_string(&session._id).unwrap().datetime());
db.db()
.collection::<Document>("sessions")
.update_one(
doc! {
"_id": &session._id.to_string(),
},
doc! {
"$set": {
"last_seen": timestamp.format().to_string()
}
},
)
.await
.expect("Failed to update a session.");
}
}
} }
if revision <= 40 { if revision <= 40 {
@@ -1240,7 +1412,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
"channel_type": "TextChannel", "channel_type": "TextChannel",
"voice": {} "voice": {}
} }
} },
) )
.await .await
.expect("Failed to update voice channels"); .expect("Failed to update voice channels");
@@ -1292,10 +1464,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
let mut doc = doc! {}; let mut doc = doc! {};
for id in server.roles.keys() { for id in server.roles.keys() {
doc.insert( doc.insert(format!("roles.{id}._id"), id);
format!("roles.{id}._id"),
id,
);
} }
db.db() db.db()
@@ -1306,6 +1475,21 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
} }
}; };
if revision <= 50 {
info!("Running migration [revision 50 / 13-04-2026]: Rename invites collection to account_invites");
db.db()
.client()
.database("admin")
.run_command(doc! {
"renameCollection": "revolt.invites",
"to": "revolt.account_invites",
"dropTarget": true
})
.await
.unwrap();
}
// Reminder to update LATEST_REVISION when adding new migrations. // Reminder to update LATEST_REVISION when adding new migrations.
LATEST_REVISION.max(revision) LATEST_REVISION.max(revision)
} }

View File

@@ -164,7 +164,7 @@ impl Bot {
mod tests { mod tests {
use crate::{Bot, FieldsBot, PartialBot, User}; use crate::{Bot, FieldsBot, PartialBot, User};
#[async_std::test] #[tokio::test]
async fn crud() { async fn crud() {
database_test!(|db| async move { database_test!(|db| async move {
let owner = User::create(&db, "Owner".to_string(), None, None) let owner = User::create(&db, "Owner".to_string(), None, None)

View File

@@ -128,7 +128,7 @@ impl Webhook {
mod tests { mod tests {
use crate::{FieldsWebhook, PartialWebhook, Webhook}; use crate::{FieldsWebhook, PartialWebhook, Webhook};
#[async_std::test] #[tokio::test]
async fn crud() { async fn crud() {
database_test!(|db| async move { database_test!(|db| async move {
let webhook_id = "webhook"; let webhook_id = "webhook";

View File

@@ -1,7 +1,8 @@
#![allow(deprecated)] #![allow(deprecated)]
use std::{borrow::Cow, collections::HashMap}; use std::{borrow::Cow, collections::HashMap};
use revolt_config::{capture_error, config}; use redis_kiss::get_connection;
use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor}; use revolt_models::v0::{self, MessageAuthor};
use revolt_permissions::OverrideField; use revolt_permissions::OverrideField;
use revolt_result::Result; use revolt_result::Result;
@@ -212,7 +213,7 @@ impl Channel {
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()), voice: data.voice.map(|voice| voice.into()),
slowmode: None slowmode: None,
}, },
v0::LegacyServerChannelType::Voice => Channel::TextChannel { v0::LegacyServerChannelType::Voice => Channel::TextChannel {
id: id.clone(), id: id.clone(),
@@ -225,7 +226,7 @@ impl Channel {
role_permissions: HashMap::new(), role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false), nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()), voice: Some(data.voice.unwrap_or_default().into()),
slowmode: None slowmode: None,
}, },
}; };
@@ -407,7 +408,10 @@ impl Channel {
/// Check whether has a user as a recipient /// Check whether has a user as a recipient
pub fn contains_user(&self, user_id: &str) -> bool { pub fn contains_user(&self, user_id: &str) -> bool {
match self { match self {
Channel::Group { recipients, .. } => recipients.contains(&String::from(user_id)), Channel::Group { recipients, .. } | Channel::DirectMessage { recipients, .. } => {
recipients.iter().any(|recipient| recipient == user_id)
}
Channel::SavedMessages { user, .. } => user == user_id,
_ => false, _ => false,
} }
} }
@@ -415,7 +419,9 @@ impl Channel {
/// Get list of recipients /// Get list of recipients
pub fn users(&self) -> Result<Vec<String>> { pub fn users(&self) -> Result<Vec<String>> {
match self { match self {
Channel::Group { recipients, .. } => Ok(recipients.to_owned()), Channel::Group { recipients, .. } | Channel::DirectMessage { recipients, .. } => {
Ok(recipients.to_owned())
}
_ => Err(create_error!(NotFound)), _ => Err(create_error!(NotFound)),
} }
} }
@@ -643,7 +649,7 @@ impl Channel {
} }
/// Acknowledge a message /// Acknowledge a message
pub async fn ack(&self, user: &str, message: &str) -> Result<()> { pub async fn ack(&self, user: &str, message: &str, amqp: &AMQP) -> Result<()> {
EventV1::ChannelAck { EventV1::ChannelAck {
id: self.id().to_string(), id: self.id().to_string(),
user: user.to_string(), user: user.to_string(),
@@ -652,17 +658,7 @@ impl Channel {
.private(user.to_string()) .private(user.to_string())
.await; .await;
#[cfg(feature = "tasks")] crate::util::acker::ack_channel(user, self.id(), message, amqp).await
crate::tasks::ack::queue_ack(
self.id().to_string(),
user.to_string(),
crate::tasks::ack::AckEvent::AckMessage {
id: message.to_string(),
},
)
.await;
Ok(())
} }
/// Remove user from a group /// Remove user from a group
@@ -774,7 +770,7 @@ impl Channel {
if let Some(amqp) = amqp { if let Some(amqp) = amqp {
if let Err(e) = amqp.delete_channel_search(self.id().to_string()).await { if let Err(e) = amqp.delete_channel_search(self.id().to_string()).await {
log::error!("Error pushing message to RabbitMQ: {e}"); log::error!("Error pushing message to RabbitMQ: {e}");
capture_error(&e); revolt_config::capture_error(&e);
} }
} }
@@ -800,7 +796,7 @@ mod tests {
use crate::{fixture, util::permissions::DatabasePermissionQuery}; use crate::{fixture, util::permissions::DatabasePermissionQuery};
#[async_std::test] #[tokio::test]
async fn permissions_group_channel() { async fn permissions_group_channel() {
database_test!(|db| async move { database_test!(|db| async move {
fixture!(db, "group_with_members", fixture!(db, "group_with_members",
@@ -826,7 +822,7 @@ mod tests {
}); });
} }
#[async_std::test] #[tokio::test]
async fn permissions_text_channel() { async fn permissions_text_channel() {
database_test!(|db| async move { database_test!(|db| async move {
fixture!(db, "server_with_roles", fixture!(db, "server_with_roles",

View File

@@ -1,4 +1,4 @@
use crate::{revolt_result::Result, Channel, FieldsChannel, PartialChannel}; use crate::{Channel, FieldsChannel, PartialChannel, revolt_result::Result, util::ChunkedDatabaseGenerator};
use revolt_permissions::OverrideField; use revolt_permissions::OverrideField;
#[cfg(feature = "mongodb")] #[cfg(feature = "mongodb")]
@@ -19,6 +19,9 @@ pub trait AbstractChannels: Sync + Send {
/// Fetch all direct messages for a user /// Fetch all direct messages for a user
async fn find_direct_messages(&self, user_id: &str) -> Result<Vec<Channel>>; async fn find_direct_messages(&self, user_id: &str) -> Result<Vec<Channel>>;
// Fetch all group dms for a user
async fn find_group_message_channels(&self, user_id: &str) -> Result<ChunkedDatabaseGenerator<Channel>>;
// Fetch saved messages channel // Fetch saved messages channel
async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel>; async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel>;
@@ -47,6 +50,9 @@ pub trait AbstractChannels: Sync + Send {
// Remove a user from a group // Remove a user from a group
async fn remove_user_from_group(&self, channel_id: &str, user_id: &str) -> Result<()>; async fn remove_user_from_group(&self, channel_id: &str, user_id: &str) -> Result<()>;
// Remove a user from all specified groups
async fn remove_user_from_groups(&self, channel_ids: Vec<String>, user_id: &str) -> Result<()>;
// Delete a channel // Delete a channel
async fn delete_channel(&self, channel_id: &Channel) -> Result<()>; async fn delete_channel(&self, channel_id: &Channel) -> Result<()>;
} }

View File

@@ -1,7 +1,8 @@
use super::AbstractChannels; use super::AbstractChannels;
use crate::{AbstractServers, Channel, FieldsChannel, IntoDocumentPath, MongoDb, PartialChannel}; use crate::{AbstractServers, Channel, FieldsChannel, IntoDocumentPath, MongoDb, PartialChannel, util::ChunkedDatabaseGenerator};
use bson::{Bson, Document}; use bson::{Bson, Document};
use futures::StreamExt; use futures::StreamExt;
use mongodb::options::ReadConcern;
use revolt_permissions::OverrideField; use revolt_permissions::OverrideField;
use revolt_result::Result; use revolt_result::Result;
@@ -69,6 +70,32 @@ impl AbstractChannels for MongoDb {
) )
} }
// Fetch all group dms for a user
async fn find_group_message_channels(&self, user_id: &str) -> Result<ChunkedDatabaseGenerator<Channel>> {
let mut session = self
.start_session()
.await
.map_err(|_| create_database_error!("start_session", COL))?;
session
.start_transaction()
.read_concern(ReadConcern::snapshot())
.await
.map_err(|_| create_database_error!("start_transaction", COL))?;
let cursor = self.col(COL)
.find(doc! {
"channel_type": "Group",
"recipients": user_id
})
.session(&mut session)
.batch_size(100)
.await
.map_err(|_| create_database_error!("find", COL))?;
Ok(ChunkedDatabaseGenerator::new_mongo(session, cursor))
}
// Fetch saved messages channel // Fetch saved messages channel
async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel> { async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel> {
query!( query!(
@@ -180,13 +207,29 @@ impl AbstractChannels for MongoDb {
.map_err(|_| create_database_error!("update_one", "channels")) .map_err(|_| create_database_error!("update_one", "channels"))
} }
// Remove a user from all specified groups
async fn remove_user_from_groups(&self, channel_ids: Vec<String>, user_id: &str) -> Result<()> {
self.col::<Document>(COL)
.update_many(
doc! {
"_id": { "$in": channel_ids },
},
doc! {
"$pull": {
"recipients": user_id
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_many", COL))
}
// Delete a channel // Delete a channel
async fn delete_channel(&self, channel: &Channel) -> Result<()> { async fn delete_channel(&self, channel: &Channel) -> Result<()> {
let id = channel.id().to_string(); let id = channel.id().to_string();
let server_id = match channel { let server_id = match channel {
Channel::TextChannel { server, .. } => { Channel::TextChannel { server, .. } => Some(server),
Some(server)
}
_ => None, _ => None,
}; };

View File

@@ -2,6 +2,7 @@ use std::collections::hash_map::Entry;
use super::AbstractChannels; use super::AbstractChannels;
use crate::ReferenceDb; use crate::ReferenceDb;
use crate::util::ChunkedDatabaseGenerator;
use crate::{Channel, FieldsChannel, PartialChannel}; use crate::{Channel, FieldsChannel, PartialChannel};
use revolt_permissions::OverrideField; use revolt_permissions::OverrideField;
use revolt_result::Result; use revolt_result::Result;
@@ -51,6 +52,23 @@ impl AbstractChannels for ReferenceDb {
.collect()) .collect())
} }
// Fetch all group dms for a user
async fn find_group_message_channels(&self, user_id: &str) -> Result<ChunkedDatabaseGenerator<Channel>> {
let channels = self.channels.lock().await;
let groups = channels
.values()
.filter(|channel| match channel {
Channel::Group { recipients, .. } => {
recipients.iter().any(|recipient| recipient == user_id)
}
_ => false,
})
.cloned()
.collect();
Ok(ChunkedDatabaseGenerator::new_reference(groups))
}
// Fetch saved messages channel // Fetch saved messages channel
async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel> { async fn find_saved_messages_channel(&self, user_id: &str) -> Result<Channel> {
let channels = self.channels.lock().await; let channels = self.channels.lock().await;
@@ -131,9 +149,9 @@ impl AbstractChannels for ReferenceDb {
// Remove a user from a group // Remove a user from a group
async fn remove_user_from_group(&self, channel: &str, user: &str) -> Result<()> { async fn remove_user_from_group(&self, channel: &str, user: &str) -> Result<()> {
let mut channels = self.channels.lock().await; let mut channels = self.channels.lock().await;
if let Some(channel_data) = channels.get_mut(channel) { if let Some(Channel::Group { recipients, .. }) = channels.get_mut(channel) {
if channel_data.users()?.contains(&String::from(user)) { if let Some(index) = recipients.iter().position(|recipient| recipient == user) {
channel_data.users()?.retain(|x| x != user); recipients.remove(index);
return Ok(()); return Ok(());
} else { } else {
return Err(create_error!(NotFound)); return Err(create_error!(NotFound));
@@ -142,6 +160,19 @@ impl AbstractChannels for ReferenceDb {
Err(create_error!(NotFound)) Err(create_error!(NotFound))
} }
// Remove a user from all specified groups
async fn remove_user_from_groups(&self, channel_ids: Vec<String>, user_id: &str) -> Result<()> {
let mut channels = self.channels.lock().await;
for channel_id in channel_ids {
if let Some(Channel::Group { recipients, .. }) = channels.get_mut(&channel_id) {
recipients.retain(|recipient| recipient != user_id);
}
};
Ok(())
}
// Delete a channel // Delete a channel
async fn delete_channel(&self, channel: &Channel) -> Result<()> { async fn delete_channel(&self, channel: &Channel) -> Result<()> {
let mut channels = self.channels.lock().await; let mut channels = self.channels.lock().await;

View File

@@ -2,6 +2,7 @@ use std::collections::HashSet;
use std::str::FromStr; use std::str::FromStr;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use revolt_models::v0;
use revolt_result::Result; use revolt_result::Result;
use ulid::Ulid; use ulid::Ulid;
@@ -11,7 +12,7 @@ use crate::Database;
static PERMISSIBLE_EMOJIS: Lazy<HashSet<String>> = Lazy::new(|| { static PERMISSIBLE_EMOJIS: Lazy<HashSet<String>> = Lazy::new(|| {
include_str!("unicode_emoji.txt") include_str!("unicode_emoji.txt")
.split('\n') .split('\n')
.map(|x| x.into()) .map(|x| x.replace('\u{FE0F}', ""))
.collect() .collect()
}); });
@@ -41,6 +42,12 @@ auto_derived!(
Server { id: String }, Server { id: String },
Detached, Detached,
} }
/// Partial representation of an emoji
pub struct PartialEmoji {
#[serde(skip_serializing_if = "Option::is_none")]
pub name: Option<String>,
}
); );
#[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_methods)]
@@ -75,13 +82,34 @@ impl Emoji {
db.detach_emoji(&self).await db.detach_emoji(&self).await
} }
/// Update an emoji
pub async fn update(&mut self, db: &Database, partial: PartialEmoji) -> Result<()> {
if let Some(name) = partial.name.clone() {
self.name = name;
}
db.update_emoji(&self.id, &partial).await?;
EventV1::EmojiUpdate {
id: self.id.clone(),
data: v0::PartialEmoji {
name: partial.name.clone(),
},
}
.p(self.parent().to_string())
.await;
Ok(())
}
/// Check whether we can use a given emoji /// Check whether we can use a given emoji
pub async fn can_use(db: &Database, emoji: &str) -> Result<bool> { pub async fn can_use(db: &Database, emoji: &str) -> Result<bool> {
if Ulid::from_str(emoji).is_ok() { if Ulid::from_str(emoji).is_ok() {
db.fetch_emoji(emoji).await?; db.fetch_emoji(emoji).await?;
Ok(true) Ok(true)
} else { } else {
Ok(PERMISSIBLE_EMOJIS.contains(emoji)) let sanitized_emoji = emoji.replace('\u{FE0F}', "");
Ok(PERMISSIBLE_EMOJIS.contains(&sanitized_emoji))
} }
} }
} }

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -61,4 +61,6 @@ pub trait AbstractMessages: Sync + Send {
/// Fetches all messages along with their author from every message in decending order /// Fetches all messages along with their author from every message in decending order
async fn fetch_all_messages(&self) -> Result<ChunkedDatabaseGenerator<MessageWithUser>>; async fn fetch_all_messages(&self) -> Result<ChunkedDatabaseGenerator<MessageWithUser>>;
async fn delete_messages_by_user(&self, user_id: &str) -> Result<()>;
} }

View File

@@ -463,6 +463,12 @@ impl AbstractMessages for MongoDb {
Ok(ChunkedDatabaseGenerator::new_mongo(session, cursor)) Ok(ChunkedDatabaseGenerator::new_mongo(session, cursor))
} }
async fn delete_messages_by_user(&self, user_id: &str) -> Result<()> {
self.delete_bulk_messages(doc! {
"author": user_id,
}).await
}
} }
impl IntoDocumentPath for FieldsMessage { impl IntoDocumentPath for FieldsMessage {

View File

@@ -1,3 +1,7 @@
use crate::{
util::ChunkedDatabaseGenerator, AppendMessage, FieldsMessage, Message, MessageQuery,
MessageWithUser, PartialMessage, ReferenceDb,
};
use futures::future::try_join_all; use futures::future::try_join_all;
use indexmap::IndexSet; use indexmap::IndexSet;
use revolt_result::Result; use revolt_result::Result;
@@ -5,11 +9,6 @@ use std::collections::HashMap;
use std::time::SystemTime; use std::time::SystemTime;
use ulid::Ulid; use ulid::Ulid;
use crate::{
util::ChunkedDatabaseGenerator, AppendMessage, FieldsMessage, Message, MessageQuery,
MessageWithUser, PartialMessage, ReferenceDb,
};
use super::AbstractMessages; use super::AbstractMessages;
#[async_trait] #[async_trait]
@@ -260,7 +259,7 @@ impl AbstractMessages for ReferenceDb {
let mut messages = self.messages.lock().await; let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) { if let Some(message) = messages.get_mut(id) {
if let Some(users) = message.reactions.get_mut(emoji) { if let Some(users) = message.reactions.get_mut(emoji) {
users.remove(&user.to_string()); users.swap_remove(&user.to_string());
} }
Ok(()) Ok(())
@@ -273,7 +272,7 @@ impl AbstractMessages for ReferenceDb {
async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> { async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> {
let mut messages = self.messages.lock().await; let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) { if let Some(message) = messages.get_mut(id) {
message.reactions.remove(emoji); message.reactions.swap_remove(emoji);
Ok(()) Ok(())
} else { } else {
Err(create_error!(NotFound)) Err(create_error!(NotFound))
@@ -373,4 +372,14 @@ impl AbstractMessages for ReferenceDb {
.collect(), .collect(),
)) ))
} }
async fn delete_messages_by_user(&self, user_id: &str) -> Result<()> {
let mut messages = self.messages.lock().await;
messages.retain(|_, message| message.author != user_id);
// TODO: remove attachments as well
Ok(())
}
} }

View File

@@ -0,0 +1,67 @@
use axum::{
extract::{FromRef, FromRequestParts},
http::request::Parts,
};
use revolt_result::{Error, Result};
use crate::{Database, MFATicket, UnvalidatedTicket, ValidatedTicket};
#[async_trait]
impl<S> FromRequestParts<S> for MFATicket
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);
if let Some(Ok(token)) = parts.headers.get("x-mfa-ticket").map(|v| v.to_str()) {
db.fetch_ticket_by_token(token).await
} else {
Err(create_error!(MissingHeaders))
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for ValidatedTicket
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 ticket = MFATicket::from_request_parts(parts, state).await?;
if ticket.validated && ticket.claim(&db).await.is_ok() {
Ok(ValidatedTicket(ticket))
} else {
Err(create_error!(InvalidToken))
}
}
}
#[async_trait]
impl<S> FromRequestParts<S> for UnvalidatedTicket
where
Database: FromRef<S>,
S: Send + Sync,
{
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self> {
let ticket = MFATicket::from_request_parts(parts, state).await?;
if !ticket.validated {
Ok(UnvalidatedTicket(ticket))
} else {
Err(create_error!(InvalidToken))
}
}
}

View File

@@ -0,0 +1,11 @@
#[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

@@ -0,0 +1,105 @@
use iso8601_timestamp::{Duration, Timestamp};
use std::ops::Deref;
use nanoid::nanoid;
use revolt_result::Result;
use crate::{Database, MultiFactorAuthentication};
auto_derived_partial!(
/// Multi-factor auth ticket
pub struct MFATicket {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Account Id
pub account_id: String,
/// Unique Token
pub token: String,
/// Whether this ticket has been validated
/// (can be used for account actions)
pub validated: bool,
/// Whether this ticket is authorised
/// (can be used to log a user in)
pub authorised: bool,
/// TOTP code at time of ticket creation
pub last_totp_code: Option<String>,
},
"PartialMFATicket"
);
/// Ticket which is guaranteed to be valid for use
///
/// If used in a Rocket guard, it will be consumed on match
#[derive(Debug, Serialize, Deserialize)]
pub struct ValidatedTicket(pub MFATicket);
/// Ticket which is guaranteed to not be valid for use
#[derive(Debug, Serialize, Deserialize)]
pub struct UnvalidatedTicket(pub MFATicket);
impl MFATicket {
/// Create a new MFA ticket
pub fn new(account_id: String, validated: bool) -> MFATicket {
MFATicket {
id: ulid::Ulid::new().to_string(),
account_id,
token: nanoid!(64),
validated,
authorised: false,
last_totp_code: None,
}
}
/// Populate an MFA ticket with valid MFA codes
pub async fn populate(&mut self, mfa: &MultiFactorAuthentication) {
self.last_totp_code = mfa.totp_token.generate_code().ok();
}
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_ticket(self).await
}
/// Check if this MFA ticket has expired
pub fn is_expired(&self) -> bool {
let now = Timestamp::now_utc();
let datetime: Timestamp = ulid::Ulid::from_string(&self.id)
.expect("Valid `ulid`")
.datetime()
.into();
now > (datetime.checked_add(Duration::minutes(5)).unwrap())
}
/// Claim and remove this MFA ticket
pub async fn claim(&self, db: &Database) -> Result<()> {
if self.is_expired() {
return Err(create_error!(InvalidToken));
}
db.delete_ticket(&self.id).await
}
}
impl Deref for ValidatedTicket {
type Target = MFATicket;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl Deref for UnvalidatedTicket {
type Target = MFATicket;
fn deref(&self) -> &Self::Target {
&self.0
}
}

View File

@@ -0,0 +1,22 @@
use revolt_result::Result;
use crate::MFATicket;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractMFATickets: Sync + Send {
/// Find ticket by token
async fn fetch_ticket_by_token(&self, token: &str) -> Result<MFATicket>;
/// Save ticket
async fn save_ticket(&self, ticket: &MFATicket) -> Result<()>;
/// Delete ticket
async fn delete_ticket(&self, id: &str) -> Result<()>;
/// Delete all expired tickets
async fn delete_expired_tickets(&self) -> Result<usize>;
}

View File

@@ -0,0 +1,67 @@
use std::time::{Duration, SystemTime};
use crate::{AbstractMFATickets, MFATicket, MongoDb};
use bson::{to_document, Document};
use iso8601_timestamp::Timestamp;
use mongodb::options::UpdateOptions;
use revolt_result::Result;
use ulid::Ulid;
const COL: &str = "mfa_tickets";
#[async_trait]
impl AbstractMFATickets for MongoDb {
/// Find ticket by token
///
/// Ticket is only valid for 5 minute
async fn fetch_ticket_by_token(&self, token: &str) -> Result<MFATicket> {
let ticket: MFATicket = query!(self, find_one, COL, doc! { "token": token })?
.ok_or_else(|| create_error!(InvalidToken))?;
if let Ok(ulid) = Ulid::from_string(&ticket.id) {
if Timestamp::from(ulid.datetime() + Duration::from_mins(5)) > Timestamp::now_utc() {
Ok(ticket)
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(InvalidToken))
}
}
/// Save ticket
async fn save_ticket(&self, ticket: &MFATicket) -> Result<()> {
self.col::<MFATicket>(COL)
.update_one(
doc! {
"_id": &ticket.id
},
doc! {
"$set": to_document(ticket).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(|_| ())
}
/// Delete ticket
async fn delete_ticket(&self, id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, id).map(|_| ())
}
/// Delete all expired tickets
async fn delete_expired_tickets(&self) -> Result<usize> {
let threshhold =
Ulid::from_datetime(SystemTime::now() - Duration::from_mins(5)).to_string();
self.col::<Document>(COL)
.delete_many(doc! {
"_id": { "$lt": threshhold }
})
.await
.map_err(|_| create_database_error!("delete_many", COL))
.map(|result| result.deleted_count as usize)
}
}

View File

@@ -0,0 +1,57 @@
use std::time::{Duration, SystemTime};
use crate::{AbstractMFATickets, MFATicket, ReferenceDb};
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use ulid::Ulid;
#[async_trait]
impl AbstractMFATickets for ReferenceDb {
/// Find ticket by token
async fn fetch_ticket_by_token(&self, token: &str) -> Result<MFATicket> {
let tickets = self.tickets.lock().await;
let ticket = tickets
.values()
.find(|ticket| ticket.token == token)
.ok_or_else(|| create_error!(InvalidToken))?;
if let Ok(ulid) = Ulid::from_string(&ticket.id) {
if Timestamp::from(ulid.datetime() + Duration::from_mins(5)) > Timestamp::now_utc() {
Ok(ticket.clone())
} else {
Err(create_error!(InvalidToken))
}
} else {
Err(create_error!(InvalidToken))
}
}
/// Save ticket
async fn save_ticket(&self, ticket: &MFATicket) -> Result<()> {
let mut tickets = self.tickets.lock().await;
tickets.insert(ticket.id.to_string(), ticket.clone());
Ok(())
}
/// Delete ticket
async fn delete_ticket(&self, id: &str) -> Result<()> {
let mut tickets = self.tickets.lock().await;
if tickets.remove(id).is_some() {
Ok(())
} else {
Err(create_error!(InvalidToken))
}
}
/// Delete all expired tickets
async fn delete_expired_tickets(&self) -> Result<usize> {
let threshhold =
Ulid::from_datetime(SystemTime::now() - Duration::from_mins(5)).to_string();
let mut tickets = self.tickets.lock().await;
let before = tickets.len();
tickets.retain(|_, ticket| ticket.id >= threshhold);
Ok(before - tickets.len())
}
}

View File

@@ -0,0 +1,81 @@
use crate::{Database, MFATicket, UnvalidatedTicket, ValidatedTicket};
use revolt_result::Error;
use rocket::{
http::Status,
outcome::Outcome,
request::{self, FromRequest},
Request,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for MFATicket {
type Error = Error;
#[allow(clippy::collapsible_match)]
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
if let Some(header_mfa_ticket) = request.headers().get("x-mfa-ticket").next() {
if let Ok(ticket) = request
.rocket()
.state::<Database>()
.expect("`Database`")
.fetch_ticket_by_token(header_mfa_ticket)
.await
{
Outcome::Success(ticket)
} else {
Outcome::Error((Status::Unauthorized, create_error!(InvalidToken)))
}
} else {
Outcome::Error((Status::Unauthorized, create_error!(MissingHeaders)))
}
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for ValidatedTicket {
type Error = Error;
#[allow(clippy::collapsible_match)]
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
match request.guard::<MFATicket>().await {
Outcome::Success(ticket) => {
if ticket.validated {
let db = request
.rocket()
.state::<Database>()
.expect("`Database`");
if ticket.claim(db).await.is_ok() {
Outcome::Success(ValidatedTicket(ticket))
} else {
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
} else {
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
}
Outcome::Forward(f) => Outcome::Forward(f),
Outcome::Error(err) => Outcome::Error(err),
}
}
}
#[rocket::async_trait]
impl<'r> FromRequest<'r> for UnvalidatedTicket {
type Error = Error;
#[allow(clippy::collapsible_match)]
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
match request.guard::<MFATicket>().await {
Outcome::Success(ticket) => {
if !ticket.validated {
Outcome::Success(UnvalidatedTicket(ticket))
} else {
Outcome::Error((Status::Forbidden, create_error!(InvalidToken)))
}
}
Outcome::Forward(f) => Outcome::Forward(f),
Outcome::Error(err) => Outcome::Error(err),
}
}
}

View File

@@ -0,0 +1,80 @@
use revolt_okapi::openapi3::{SecurityScheme, SecuritySchemeData};
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
};
use crate::{MFATicket, ValidatedTicket, UnvalidatedTicket};
impl<'r> OpenApiFromRequest<'r> for MFATicket {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("MFA Ticket".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"MFA Ticket".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-mfa-ticket".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authorise a request.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}
impl<'r> OpenApiFromRequest<'r> for ValidatedTicket {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Valid MFA Ticket".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Valid MFA Ticket".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-mfa-ticket".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authorise a request.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}
impl<'r> OpenApiFromRequest<'r> for UnvalidatedTicket {
fn from_request_input(
_gen: &mut OpenApiGenerator,
_name: String,
_required: bool,
) -> revolt_rocket_okapi::Result<RequestHeaderInput> {
let mut requirements = schemars::Map::new();
requirements.insert("Unvalidated MFA Ticket".to_owned(), vec![]);
Ok(RequestHeaderInput::Security(
"Unvalidated MFA Ticket".to_owned(),
SecurityScheme {
data: SecuritySchemeData::ApiKey {
name: "x-mfa-ticket".to_owned(),
location: "header".to_owned(),
},
description: Some("Used to authorise a request.".to_owned()),
extensions: schemars::Map::new(),
},
requirements,
))
}
}

View File

@@ -17,6 +17,10 @@ mod server_members;
mod servers; mod servers;
mod user_settings; mod user_settings;
mod users; mod users;
mod accounts;
mod account_invites;
mod sessions;
mod mfa_tickets;
pub use admin_migrations::*; pub use admin_migrations::*;
pub use bots::*; pub use bots::*;
@@ -37,6 +41,10 @@ pub use server_members::*;
pub use servers::*; pub use servers::*;
pub use user_settings::*; pub use user_settings::*;
pub use users::*; pub use users::*;
pub use accounts::*;
pub use account_invites::*;
pub use sessions::*;
pub use mfa_tickets::*;
use crate::{Database, ReferenceDb}; use crate::{Database, ReferenceDb};
@@ -65,6 +73,10 @@ pub trait AbstractDatabase:
+ servers::AbstractServers + servers::AbstractServers
+ user_settings::AbstractUserSettings + user_settings::AbstractUserSettings
+ users::AbstractUsers + users::AbstractUsers
+ accounts::AbstractAccounts
+ account_invites::AbstractAccountInvites
+ sessions::AbstractSessions
+ mfa_tickets::AbstractMFATickets
{ {
} }

View File

@@ -28,6 +28,9 @@ auto_derived_partial!(
/// Member's nickname /// Member's nickname
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub nickname: Option<String>, pub nickname: Option<String>,
/// Member's pronouns
#[serde(skip_serializing_if = "Option::is_none")]
pub pronouns: Option<String>,
/// Avatar attachment /// Avatar attachment
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<File>, pub avatar: Option<File>,
@@ -65,6 +68,7 @@ auto_derived!(
/// Optional fields on server member object /// Optional fields on server member object
pub enum FieldsMember { pub enum FieldsMember {
Nickname, Nickname,
Pronouns,
Avatar, Avatar,
Roles, Roles,
Timeout, Timeout,
@@ -88,6 +92,7 @@ impl Default for Member {
id: Default::default(), id: Default::default(),
joined_at: Timestamp::now_utc(), joined_at: Timestamp::now_utc(),
nickname: None, nickname: None,
pronouns: None,
avatar: None, avatar: None,
roles: vec![], roles: vec![],
timeout: None, timeout: None,
@@ -231,6 +236,7 @@ impl Member {
FieldsMember::JoinedAt => {} FieldsMember::JoinedAt => {}
FieldsMember::Avatar => self.avatar = None, FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None, FieldsMember::Nickname => self.nickname = None,
FieldsMember::Pronouns => self.pronouns = None,
FieldsMember::Roles => self.roles.clear(), FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None, FieldsMember::Timeout => self.timeout = None,
FieldsMember::CanReceive => self.can_receive = true, FieldsMember::CanReceive => self.can_receive = true,
@@ -314,7 +320,7 @@ mod tests {
use crate::{Member, PartialMember, RemovalIntention, Server, User}; use crate::{Member, PartialMember, RemovalIntention, Server, User};
#[async_std::test] #[tokio::test]
async fn muted_member_rejoin() { async fn muted_member_rejoin() {
database_test!(|db| async move { database_test!(|db| async move {
match db { match db {

View File

@@ -129,4 +129,7 @@ pub trait AbstractServerMembers: Sync + Send {
/// Fetch all members who have been marked for deletion. /// Fetch all members who have been marked for deletion.
async fn remove_dangling_members(&self) -> Result<()>; async fn remove_dangling_members(&self) -> Result<()>;
/// Removes a user from every server they are in
async fn clear_memberships(&self, user_id: &str) -> Result<()>;
} }

View File

@@ -326,6 +326,17 @@ impl AbstractServerMembers for MongoDb {
.map(|_| ()) .map(|_| ())
.map_err(|_| create_database_error!("count_documents", COL)) .map_err(|_| create_database_error!("count_documents", COL))
} }
/// Removes a user from every server they are in
///
/// **This should only be used for account deletion.**
async fn clear_memberships(&self, user_id: &str) -> Result<()> {
self.col::<Member>(COL)
.delete_many(doc! { "_id.user": user_id })
.await
.map(|_| ())
.map_err(|_| create_database_error!("delete_many", COL))
}
} }
impl IntoDocumentPath for FieldsMember { impl IntoDocumentPath for FieldsMember {
@@ -334,6 +345,7 @@ impl IntoDocumentPath for FieldsMember {
FieldsMember::JoinedAt => Some("joined_at"), FieldsMember::JoinedAt => Some("joined_at"),
FieldsMember::Avatar => Some("avatar"), FieldsMember::Avatar => Some("avatar"),
FieldsMember::Nickname => Some("nickname"), FieldsMember::Nickname => Some("nickname"),
FieldsMember::Pronouns => Some("pronouns"),
FieldsMember::Roles => Some("roles"), FieldsMember::Roles => Some("roles"),
FieldsMember::Timeout => Some("timeout"), FieldsMember::Timeout => Some("timeout"),
FieldsMember::CanPublish => Some("can_publish"), FieldsMember::CanPublish => Some("can_publish"),

View File

@@ -200,4 +200,13 @@ impl AbstractServerMembers for ReferenceDb {
async fn remove_dangling_members(&self) -> Result<()> { async fn remove_dangling_members(&self) -> Result<()> {
todo!() todo!()
} }
/// Removes a user from every server they are in
async fn clear_memberships(&self, user_id: &str) -> Result<()> {
let mut server_members = self.server_members.lock().await;
server_members.retain(|_, v| v.id.user != user_id);
Ok(())
}
} }

View File

@@ -87,6 +87,9 @@ auto_derived_partial!(
/// Ranking of this role /// Ranking of this role
#[serde(default)] #[serde(default)]
pub rank: i64, pub rank: i64,
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<File>,
}, },
"PartialRole" "PartialRole"
); );
@@ -130,6 +133,7 @@ auto_derived!(
/// Optional fields on server object /// Optional fields on server object
pub enum FieldsRole { pub enum FieldsRole {
Colour, Colour,
Icon,
} }
); );
@@ -315,6 +319,7 @@ impl Role {
colour: self.colour, colour: self.colour,
hoist: Some(self.hoist), hoist: Some(self.hoist),
rank: Some(self.rank), rank: Some(self.rank),
icon: self.icon,
} }
} }
@@ -328,6 +333,7 @@ impl Role {
colour: None, colour: None,
hoist: false, hoist: false,
permissions: Default::default(), permissions: Default::default(),
icon: None,
}; };
db.insert_role(&server.id, &role).await?; db.insert_role(&server.id, &role).await?;
@@ -377,6 +383,7 @@ impl Role {
pub fn remove_field(&mut self, field: &FieldsRole) { pub fn remove_field(&mut self, field: &FieldsRole) {
match field { match field {
FieldsRole::Colour => self.colour = None, FieldsRole::Colour => self.colour = None,
FieldsRole::Icon => self.icon = None,
} }
} }
@@ -423,7 +430,7 @@ mod tests {
use crate::{fixture, util::permissions::DatabasePermissionQuery}; use crate::{fixture, util::permissions::DatabasePermissionQuery};
#[async_std::test] #[tokio::test]
async fn permissions() { async fn permissions() {
database_test!(|db| async move { database_test!(|db| async move {
fixture!(db, "server_with_roles", fixture!(db, "server_with_roles",

View File

@@ -17,6 +17,8 @@ pub trait AbstractServers: Sync + Send {
/// Fetch a servers by their ids /// Fetch a servers by their ids
async fn fetch_servers<'a>(&self, ids: &'a [String]) -> Result<Vec<Server>>; async fn fetch_servers<'a>(&self, ids: &'a [String]) -> Result<Vec<Server>>;
async fn fetch_owned_servers(&self, user_id: &str) -> Result<Vec<Server>>;
/// Update a server with new information /// Update a server with new information
async fn update_server( async fn update_server(
&self, &self,

View File

@@ -43,6 +43,17 @@ impl AbstractServers for MongoDb {
.await) .await)
} }
async fn fetch_owned_servers(&self, user_id: &str) -> Result<Vec<Server>> {
query!(
self,
find,
COL,
doc! {
"owner": user_id
}
)
}
/// Update a server with new information /// Update a server with new information
async fn update_server( async fn update_server(
&self, &self,
@@ -77,7 +88,7 @@ impl AbstractServers for MongoDb {
}, },
doc! { doc! {
"$set": { "$set": {
"roles.".to_owned() + &role.id: to_document(role) "roles.".to_owned() + role.id.as_str(): to_document(role)
.map_err(|_| create_database_error!("to_document", "role"))? .map_err(|_| create_database_error!("to_document", "role"))?
} }
}, },
@@ -172,6 +183,7 @@ impl IntoDocumentPath for FieldsRole {
fn as_path(&self) -> Option<&'static str> { fn as_path(&self) -> Option<&'static str> {
Some(match self { Some(match self {
FieldsRole::Colour => "colour", FieldsRole::Colour => "colour",
FieldsRole::Icon => "icon",
}) })
} }
} }

View File

@@ -40,6 +40,16 @@ impl AbstractServers for ReferenceDb {
.collect() .collect()
} }
async fn fetch_owned_servers(&self, user_id: &str) -> Result<Vec<Server>> {
let servers = self.servers.lock().await;
Ok(servers
.values()
.filter(|server| server.owner == user_id)
.cloned()
.collect())
}
/// Update a server with new information /// Update a server with new information
async fn update_server( async fn update_server(
&self, &self,

View File

@@ -0,0 +1,24 @@
use axum::{extract::{FromRef, FromRequestParts}, http::request::Parts};
use revolt_result::{create_error, Error, Result};
use crate::{Database, Session};
#[async_trait]
impl<S> FromRequestParts<S> for Session
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);
if let Some(Ok(token)) = parts.headers.get("x-session-token").map(|v| v.to_str()) {
db.fetch_session_by_token(token).await
} else {
Err(create_error!(MissingHeaders))
}
}
}

View File

@@ -0,0 +1,11 @@
#[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

@@ -0,0 +1,68 @@
use iso8601_timestamp::Timestamp;
use crate::{events::client::EventV1, Database};
use revolt_result::Result;
auto_derived_partial!(
/// Session information
pub struct Session {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// User Id
pub user_id: String,
/// Session token
pub token: String,
/// Display name
pub name: String,
/// When the session was last logged in
pub last_seen: Timestamp,
/// Where the session originated from
///
/// This could be used to differentiate sessions that come from staging/test vs prod, etc.
#[serde(skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
/// Web Push subscription
#[serde(skip_serializing_if = "Option::is_none")]
pub subscription: Option<WebPushSubscription>,
},
"PartialSession"
);
auto_derived!(
/// Web Push subscription
pub struct WebPushSubscription {
pub endpoint: String,
pub p256dh: String,
pub auth: String,
}
);
impl Session {
/// Save model
pub async fn save(&self, db: &Database) -> Result<()> {
db.save_session(self).await
}
/// Delete session
pub async fn delete(self, db: &Database) -> Result<()> {
// Delete from database
db.delete_session(&self.id).await?;
// Create and push event
EventV1::DeleteSession {
user_id: self.user_id.clone(),
session_id: self.id,
}
.private(self.user_id)
.await;
Ok(())
}
}

View File

@@ -0,0 +1,37 @@
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
use crate::Session;
#[cfg(feature = "mongodb")]
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractSessions: Sync + Send {
/// Find session by id
async fn fetch_session(&self, id: &str) -> Result<Session>;
/// Find sessions by user id
async fn fetch_sessions(&self, user_id: &str) -> Result<Vec<Session>>;
/// Find sessions by user ids
async fn fetch_sessions_with_subscription(&self, user_ids: &[String]) -> Result<Vec<Session>>;
/// Find session by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session>;
/// Save session
async fn save_session(&self, session: &Session) -> Result<()>;
/// Delete session
async fn delete_session(&self, id: &str) -> Result<()>;
/// Delete session
async fn delete_all_sessions(&self, user_id: &str, ignore: Option<String>) -> Result<()>;
/// Remove push subscription for a session by session id
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()>;
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()>;
}

View File

@@ -0,0 +1,142 @@
use crate::{AbstractSessions, MongoDb, Session};
use bson::{to_bson, to_document};
use iso8601_timestamp::Timestamp;
use mongodb::options::UpdateOptions;
use revolt_result::Result;
const COL: &str = "sessions";
#[async_trait]
impl AbstractSessions for MongoDb {
/// Find session by id
async fn fetch_session(&self, id: &str) -> Result<Session> {
query!(self, find_one_by_id, COL, id)?.ok_or_else(|| create_error!(UnknownUser))
}
/// Find sessions by user id
async fn fetch_sessions(&self, user_id: &str) -> Result<Vec<Session>> {
query!(
self,
find,
COL,
doc! {
"user_id": user_id
}
)
}
/// Find sessions by user ids
async fn fetch_sessions_with_subscription(&self, user_ids: &[String]) -> Result<Vec<Session>> {
query!(
self,
find,
COL,
doc! {
"user_id": {
"$in": user_ids
},
"subscription": {
"$exists": true
}
}
)
}
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
query!(
self,
find_one,
COL,
doc! {
"token": token
}
)?
.ok_or_else(|| create_error!(InvalidSession))
}
/// Save session
async fn save_session(&self, session: &Session) -> Result<()> {
self.col::<Session>(COL)
.update_one(
doc! {
"_id": &session.id
},
doc! {
"$set": to_document(session).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(|_| ())
}
/// Delete session
async fn delete_session(&self, id: &str) -> Result<()> {
self.col::<Session>(COL)
.delete_one(doc! {
"_id": id
})
.await
.map_err(|_| create_database_error!("delete_one", COL))
.map(|_| ())
}
/// Delete session
async fn delete_all_sessions(&self, user_id: &str, ignore: Option<String>) -> Result<()> {
let mut query = doc! {
"user_id": user_id
};
if let Some(id) = ignore {
query.insert(
"_id",
doc! {
"$ne": id
},
);
}
self.col::<Session>(COL)
.delete_many(query)
.await
.map_err(|_| create_database_error!("delete_one", COL))
.map(|_| ())
}
/// Remove push subscription for a session by session id
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> {
self.col::<Session>(COL)
.update_one(
doc! {
"_id": session_id
},
doc! {
"$unset": {
"subscription": 1
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()> {
self.col::<Session>(COL)
.update_one(
doc! {
"_id": session_id
},
doc! {
"$set": {
"last_seen": to_bson(&when).unwrap()
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
}

View File

@@ -0,0 +1,101 @@
use crate::{AbstractSessions, ReferenceDb, Session};
use iso8601_timestamp::Timestamp;
use revolt_result::Result;
#[async_trait]
impl AbstractSessions for ReferenceDb {
/// Find session by id
async fn fetch_session(&self, id: &str) -> Result<Session> {
let sessions = self.sessions.lock().await;
sessions
.get(id)
.cloned()
.ok_or_else(|| create_error!(UnknownUser))
}
/// Find sessions by user id
async fn fetch_sessions(&self, user_id: &str) -> Result<Vec<Session>> {
let sessions = self.sessions.lock().await;
Ok(sessions
.values()
.filter(|session| session.user_id == user_id)
.cloned()
.collect())
}
/// Find sessions by user ids
async fn fetch_sessions_with_subscription(&self, user_ids: &[String]) -> Result<Vec<Session>> {
let sessions = self.sessions.lock().await;
Ok(sessions
.values()
.filter(|session| session.subscription.is_some() && user_ids.contains(&session.user_id))
.cloned()
.collect())
}
/// Find session by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
let sessions = self.sessions.lock().await;
sessions
.values()
.find(|session| session.token == token)
.cloned()
.ok_or_else(|| create_error!(InvalidSession))
}
/// Save session
async fn save_session(&self, session: &Session) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.insert(session.id.to_string(), session.clone());
Ok(())
}
/// Delete session
async fn delete_session(&self, id: &str) -> Result<()> {
let mut sessions = self.sessions.lock().await;
if sessions.remove(id).is_some() {
Ok(())
} else {
Err(create_error!(InvalidSession))
}
}
/// Delete session
async fn delete_all_sessions(&self, user_id: &str, ignore: Option<String>) -> Result<()> {
let mut sessions = self.sessions.lock().await;
sessions.retain(|_, session| {
if session.user_id == user_id {
if let Some(ignore) = &ignore {
ignore == &session.id
} else {
false
}
} else {
true
}
});
Ok(())
}
/// Remove push subscription for a session by session id
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(session_id) {
session.subscription = None;
};
Ok(())
}
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()> {
let mut sessions = self.sessions.lock().await;
if let Some(session) = sessions.get_mut(session_id) {
session.last_seen = when;
};
Ok(())
}
}

View File

@@ -0,0 +1,30 @@
use crate::{Database, Session};
use revolt_result::Error;
use rocket::{
http::Status,
request::{FromRequest, Outcome},
Request,
};
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Session {
type Error = Error;
async fn from_request(request: &'r Request<'_>) -> Outcome<Self, Self::Error> {
if let Some(token) = request.headers().get("x-session-token").next() {
if let Ok(session) = request
.rocket()
.state::<Database>()
.expect("`Database`")
.fetch_session_by_token(token)
.await
{
Outcome::Success(session)
} else {
Outcome::Error((Status::Unauthorized, create_error!(InvalidSession)))
}
} else {
Outcome::Error((Status::Unauthorized, create_error!(MissingHeaders)))
}
}
}

View File

@@ -0,0 +1,32 @@
use revolt_okapi::openapi3::{SecurityScheme, SecuritySchemeData};
use revolt_rocket_okapi::{
gen::OpenApiGenerator,
request::{OpenApiFromRequest, RequestHeaderInput},
};
use crate::Session;
impl<'r> OpenApiFromRequest<'r> for Session {
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,12 +1,16 @@
use std::{collections::HashSet, str::FromStr, time::Duration}; use std::{collections::HashSet, str::FromStr, time::Duration};
use crate::{events::client::EventV1, Database, File, RatelimitEvent, AMQP}; use crate::{
events::client::EventV1,
util::email::{email_templates, send_email},
Database, File, RatelimitEvent, AMQP,
};
use authifier::config::{EmailVerificationConfig, Template};
use futures::future::join_all; use futures::future::join_all;
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use regex::{Regex, RegexBuilder};
use revolt_config::{config, FeaturesLimits}; use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{self, UserBadges, UserFlags}; use revolt_models::v0::{self, UserBadges, UserFlags};
use revolt_presence::filter_online; use revolt_presence::filter_online;
@@ -27,6 +31,9 @@ auto_derived_partial!(
/// Display name /// Display name
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub display_name: Option<String>, pub display_name: Option<String>,
/// User's pronouns
#[serde(skip_serializing_if = "Option::is_none", default)]
pub pronouns: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
/// Avatar attachment /// Avatar attachment
pub avatar: Option<File>, pub avatar: Option<File>,
@@ -72,6 +79,7 @@ auto_derived!(
ProfileContent, ProfileContent,
ProfileBackground, ProfileBackground,
DisplayName, DisplayName,
Pronouns,
// internal fields // internal fields
Suspension, Suspension,
@@ -163,6 +171,13 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
set.into_iter().collect() set.into_iter().collect()
}); });
static BLOCKED_USERNAME_PATTERNS: Lazy<Regex> = Lazy::new(|| {
RegexBuilder::new("`{3}|(discord|rvlt|guilded|stt)\\.gg|(revolt|stoat)\\.chat|https?:\\/\\/")
.case_insensitive(true)
.build()
.unwrap()
});
#[allow(clippy::derivable_impls)] #[allow(clippy::derivable_impls)]
impl Default for User { impl Default for User {
fn default() -> Self { fn default() -> Self {
@@ -171,6 +186,7 @@ impl Default for User {
username: Default::default(), username: Default::default(),
discriminator: Default::default(), discriminator: Default::default(),
display_name: Default::default(), display_name: Default::default(),
pronouns: Default::default(),
avatar: Default::default(), avatar: Default::default(),
relations: Default::default(), relations: Default::default(),
badges: Default::default(), badges: Default::default(),
@@ -198,11 +214,13 @@ impl User {
I: Into<Option<String>>, I: Into<Option<String>>,
D: Into<Option<PartialUser>>, D: Into<Option<PartialUser>>,
{ {
let username = User::validate_username(username)?; let new_username = User::sanitise_username(&username).await?;
User::validate_username(&new_username)?;
let mut user = User { let mut user = User {
id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()), id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()),
discriminator: User::find_discriminator(db, &username, None).await?, discriminator: User::find_discriminator(db, &new_username, None).await?,
username, username: new_username.clone(),
last_acknowledged_policy_change: Timestamp::now_utc(), last_acknowledged_policy_change: Timestamp::now_utc(),
..Default::default() ..Default::default()
}; };
@@ -278,39 +296,40 @@ impl User {
} }
} }
/// Sanitise and validate a username can be used /// Validate a username
pub fn validate_username(username: String) -> Result<String> { ///
// Copy the username for validation /// This will check if the username is a blocked name or contains a blocked pattern.
fn validate_username(username: &str) -> Result<()> {
let username_lowercase = username.to_lowercase(); let username_lowercase = username.to_lowercase();
// Block homoglyphs const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"];
if decancer::cure(&username_lowercase).into_str() != username_lowercase {
if BLOCKED_USERNAMES.contains(&username_lowercase.as_str())
|| BLOCKED_USERNAME_PATTERNS.is_match(username)
{
return Err(create_error!(InvalidUsername)); return Err(create_error!(InvalidUsername));
} }
// Ensure the username itself isn't blocked Ok(())
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"]; }
for username in BLOCKED_USERNAMES { /// Sanitise a username
if username_lowercase == *username { ///
return Err(create_error!(InvalidUsername)); /// This will clean up Unicode homoglyphs and pad to the min username length with underscores.
} async fn sanitise_username(username: &str) -> Result<String> {
} let options = decancer::Options::default().retain_capitalization();
let mut username = decancer::cure(username, options)
.map_err(|_| create_error!(InvalidUsername))?
.to_string();
// Ensure none of the following substrings show up in the username let config = revolt_config::config().await;
const BLOCKED_SUBSTRINGS: &[&str] = &[ let username_length_diff = config
"```", .api
"discord.gg", .users
"rvlt.gg", .min_username_length
"guilded.gg", .saturating_sub(username.len());
"https://", if username_length_diff > 0 {
"http://", username.push_str(&"_".repeat(username_length_diff))
];
for substr in BLOCKED_SUBSTRINGS {
if username_lowercase.contains(substr) {
return Err(create_error!(InvalidUsername));
}
} }
Ok(username) Ok(username)
@@ -427,12 +446,14 @@ impl User {
/// Update a user's username /// Update a user's username
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> { pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
let username = User::validate_username(username)?; let new_username = User::sanitise_username(&username).await?;
if self.username.to_lowercase() == username.to_lowercase() { User::validate_username(&new_username)?;
if self.username.to_lowercase() == new_username.to_lowercase() {
self.update( self.update(
db, db,
PartialUser { PartialUser {
username: Some(username), username: Some(new_username),
..Default::default() ..Default::default()
}, },
vec![], vec![],
@@ -445,12 +466,12 @@ impl User {
discriminator: Some( discriminator: Some(
User::find_discriminator( User::find_discriminator(
db, db,
&username, &new_username,
Some((self.discriminator.to_string(), self.id.clone())), Some((self.discriminator.to_string(), self.id.clone())),
) )
.await?, .await?,
), ),
username: Some(username), username: Some(new_username),
..Default::default() ..Default::default()
}, },
vec![], vec![],
@@ -705,6 +726,7 @@ impl User {
} }
} }
FieldsUser::DisplayName => self.display_name = None, FieldsUser::DisplayName => self.display_name = None,
FieldsUser::Pronouns => self.pronouns = None,
FieldsUser::Suspension => self.suspended_until = None, FieldsUser::Suspension => self.suspended_until = None,
FieldsUser::None => {} FieldsUser::None => {}
} }
@@ -720,22 +742,11 @@ impl User {
duration_days: Option<usize>, duration_days: Option<usize>,
reason: Option<Vec<String>>, reason: Option<Vec<String>>,
) -> Result<()> { ) -> Result<()> {
let authifier = db.clone().to_authifier().await; let mut account = db.fetch_account(&self.id).await?;
let mut account = authifier
.database
.find_account(&self.id)
.await
.map_err(|_| create_error!(InternalError))?;
account account.disable(db).await?;
.disable(&authifier)
.await
.map_err(|_| create_error!(InternalError))?;
account account.delete_all_sessions(db, None).await?;
.delete_all_sessions(&authifier, None)
.await
.map_err(|_| create_error!(InternalError))?;
self.update( self.update(
db, db,
@@ -751,18 +762,15 @@ impl User {
.await?; .await?;
if let Some(reason) = reason { if let Some(reason) = reason {
if let EmailVerificationConfig::Enabled { smtp, .. } = let config = config().await;
authifier.config.email_verification
{ if !config.api.smtp.host.is_empty() {
smtp.send_email( let templates = email_templates().await;
send_email(
&config.api.smtp,
account.email.clone(), account.email.clone(),
// maybe move this to common area? &templates.suspension,
&Template {
title: "Account Suspension".to_string(),
html: Some(include_str!("../../../templates/suspension.html").to_owned()),
text: include_str!("../../../templates/suspension.txt").to_owned(),
url: Default::default(),
},
json!({ json!({
"email": account.email, "email": account.email,
"list": reason.join(", "), "list": reason.join(", "),
@@ -811,7 +819,9 @@ impl User {
db, db,
PartialUser { PartialUser {
username: Some(format!("Deleted User {}", self.id)), username: Some(format!("Deleted User {}", self.id)),
discriminator: Some("0000".to_string()),
flags: Some(2), flags: Some(2),
relations: Some(Vec::new()),
..Default::default() ..Default::default()
}, },
vec![ vec![
@@ -839,4 +849,160 @@ impl User {
badges badges
} }
/// Removes all relationships which include the user
pub async fn clear_relationships(&self, db: &Database) -> Result<()> {
let user_ids = self
.relations
.iter()
.flatten()
.map(|relation| relation.id.clone())
.collect();
db.clear_user_relationships(&self.id, user_ids).await
}
/// Removes user from all joined groups
pub async fn remove_from_all_groups(&self, db: &Database) -> Result<()> {
let mut generator = db.find_group_message_channels(&self.id).await?;
while let Some(groups) = generator.next_n(100).await? {
let ids = groups
.into_iter()
.map(|channel| channel.id().to_string())
.collect();
db.remove_user_from_groups(ids, &self.id).await?;
}
Ok(())
}
/// Deletes the user along with:
/// - deletes owned bots, servers and messages
/// - removes user from all groups
/// - clears relationships
pub async fn delete(&mut self, db: &Database) -> Result<()> {
for bot in db.fetch_bots_by_user(&self.id).await? {
bot.delete(db).await?;
}
for server in db.fetch_owned_servers(&self.id).await? {
server.delete(db).await?;
}
self.remove_from_all_groups(db).await?;
db.clear_memberships(&self.id).await?;
self.clear_relationships(db).await?;
db.delete_messages_by_user(&self.id).await?;
self.mark_deleted(db).await?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use crate::User;
#[test]
fn username_validation_blocked_names() {
let username_admin = "Admin";
let username_revolt = "Revolt";
let username_stoat = "Stoat";
let username_allowed = "Allowed";
assert!(User::validate_username(username_admin).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_allowed).is_ok());
}
#[test]
fn username_validation_blocked_patterns() {
let username_grave = "```_test";
let username_discord = "discord.gg_test";
let username_rvlt = "rvlt.gg_test";
let username_guilded = "guilded.gg_test";
let username_stt = "stt.gg_test";
let username_revolt = "revolt.chat_test";
let username_stoat = "stoat.chat_test";
let username_http = "http://_test";
let username_https = "https://_test";
assert!(User::validate_username(username_grave).is_err());
assert!(User::validate_username(username_discord).is_err());
assert!(User::validate_username(username_rvlt).is_err());
assert!(User::validate_username(username_guilded).is_err());
assert!(User::validate_username(username_stt).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_http).is_err());
assert!(User::validate_username(username_https).is_err());
}
#[tokio::test]
async fn username_sanitisation_clean() {
let username_clean = "Test";
let username_clean_sanitised = User::sanitise_username(username_clean).await;
assert!(username_clean_sanitised.is_ok());
assert_eq!(username_clean, username_clean_sanitised.unwrap());
}
#[tokio::test]
async fn username_sanitisation_homoglyphs() {
let username_homoglyphs = "𝔽𝕌Ňℕy";
let username_homoglyphs_sanitised =
User::sanitise_username(username_homoglyphs).await.unwrap();
assert_ne!(username_homoglyphs, username_homoglyphs_sanitised);
assert_eq!("funny", username_homoglyphs_sanitised);
}
#[tokio::test]
async fn username_sanitisation_padding() {
let username_padding = "a";
let username = User::sanitise_username(username_padding).await.unwrap();
assert_eq!("a_", username);
}
#[tokio::test]
async fn create_user() {
use revolt_result::Result;
database_test!(|db| async move {
let mut created_clean = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
assert_eq!("Test", created_clean.username);
created_clean
.update_username(&db, "Test2".to_string())
.await
.unwrap();
assert_eq!("Test2", created_clean.username);
let created_invalid_result: Result<_> =
User::create(&db, "stoat.chat".to_string(), None, None).await;
assert!(created_invalid_result.is_err());
let mut updated_invalid = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
let updated_invalid_update_result = updated_invalid
.update_username(&db, "http://test".to_string())
.await;
assert!(updated_invalid_update_result.is_err());
});
}
} }

View File

@@ -1,5 +1,3 @@
use authifier::models::Session;
use iso8601_timestamp::Timestamp;
use revolt_result::Result; use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User}; use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -19,9 +17,6 @@ pub trait AbstractUsers: Sync + Send {
/// Fetch a user from the database by their username /// Fetch a user from the database by their username
async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User>; async fn fetch_user_by_username(&self, username: &str, discriminator: &str) -> Result<User>;
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session>;
/// Fetch multiple users by their ids /// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>>; async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>>;
@@ -61,8 +56,6 @@ pub trait AbstractUsers: Sync + Send {
/// Delete a user by their id /// Delete a user by their id
async fn delete_user(&self, id: &str) -> Result<()>; async fn delete_user(&self, id: &str) -> Result<()>;
/// Remove push subscription for a session by session id (TODO: remove) /// Removes all relationships with the user from the list of users
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()>; async fn clear_user_relationships(&self, target_id: &str, user_ids: Vec<String>) -> Result<()>;
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()>;
} }

View File

@@ -1,7 +1,5 @@
use ::mongodb::options::{Collation, CollationStrength, FindOneOptions, FindOptions}; use ::mongodb::options::{Collation, CollationStrength, FindOneOptions, FindOptions};
use authifier::models::Session;
use futures::StreamExt; use futures::StreamExt;
use iso8601_timestamp::Timestamp;
use revolt_result::Result; use revolt_result::Result;
use crate::DocumentId; use crate::DocumentId;
@@ -47,17 +45,6 @@ impl AbstractUsers for MongoDb {
.ok_or_else(|| create_error!(NotFound)) .ok_or_else(|| create_error!(NotFound))
} }
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
self.col::<Session>("sessions")
.find_one(doc! {
"token": token
})
.await
.map_err(|_| create_database_error!("find_one", "sessions"))?
.ok_or_else(|| create_error!(InvalidSession))
}
/// Fetch multiple users by their ids /// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>> { async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>> {
Ok(self Ok(self
@@ -321,41 +308,22 @@ impl AbstractUsers for MongoDb {
query!(self, delete_one_by_id, COL, id).map(|_| ()) query!(self, delete_one_by_id, COL, id).map(|_| ())
} }
/// Remove push subscription for a session by session id (TODO: remove) /// Removes all relationships with the user from the list of users
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> { async fn clear_user_relationships(&self, target_id: &str, user_ids: Vec<String>) -> Result<()> {
self.col::<User>("sessions") self.col::<User>(COL)
.update_one( .update_many(
doc! { "_id": { "$in": user_ids } },
doc! { doc! {
"_id": session_id "$pull": {
}, "relations": {
doc! { "_id": target_id.to_string()
"$unset": { }
"subscription": 1
} }
}, },
) )
.await .await
.map(|_| ()) .map(|_| ())
.map_err(|_| create_database_error!("update_one", "sessions")) .map_err(|_| create_database_error!("bulk_write", COL))
}
async fn update_session_last_seen(&self, session_id: &str, when: Timestamp) -> Result<()> {
let formatted: &str = &when.format();
self.col::<Session>("sessions")
.update_one(
doc! {
"_id": session_id
},
doc! {
"$set": {
"last_seen": formatted
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", "sessions"))
} }
} }
@@ -368,6 +336,7 @@ impl IntoDocumentPath for FieldsUser {
FieldsUser::StatusPresence => "status.presence", FieldsUser::StatusPresence => "status.presence",
FieldsUser::StatusText => "status.text", FieldsUser::StatusText => "status.text",
FieldsUser::DisplayName => "display_name", FieldsUser::DisplayName => "display_name",
FieldsUser::Pronouns => "pronouns",
FieldsUser::Suspension => "suspended_until", FieldsUser::Suspension => "suspended_until",
FieldsUser::None => "none", FieldsUser::None => "none",
}) })

View File

@@ -1,5 +1,3 @@
use authifier::models::Session;
use iso8601_timestamp::Timestamp;
use revolt_result::Result; use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User}; use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -42,11 +40,6 @@ impl AbstractUsers for ReferenceDb {
.ok_or_else(|| create_error!(NotFound)) .ok_or_else(|| create_error!(NotFound))
} }
/// Fetch a session from the database by token
async fn fetch_session_by_token(&self, _token: &str) -> Result<Session> {
todo!()
}
/// Fetch multiple users by their ids /// Fetch multiple users by their ids
async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>> { async fn fetch_users<'a>(&self, ids: &'a [String]) -> Result<Vec<User>> {
let users = self.users.lock().await; let users = self.users.lock().await;
@@ -165,12 +158,22 @@ impl AbstractUsers for ReferenceDb {
} }
} }
/// Remove push subscription for a session by session id (TODO: remove) /// Removes all relationships with the user from the list of users
async fn remove_push_subscription_by_session_id(&self, _session_id: &str) -> Result<()> { async fn clear_user_relationships(
todo!() &self,
} target_id: &str,
user_ids: Vec<String>,
) -> Result<()> {
let mut users = self.users.lock().await;
async fn update_session_last_seen(&self, _session_id: &str, _when: Timestamp) -> Result<()> { for user_id in user_ids {
todo!() if let Some(user) = users.get_mut(&user_id) {
if let Some(relations) = &mut user.relations {
relations.retain(|relation| relation.id != target_id);
}
}
}
Ok(())
} }
} }

View File

@@ -1,12 +1,12 @@
use authifier::models::Session;
use rocket::http::Status; use rocket::http::Status;
use rocket::request::{self, FromRequest, Outcome, Request}; use rocket::request::{self, FromRequest, Outcome, Request};
use revolt_result::Error;
use crate::{Database, User}; use crate::{Database, Session, User};
#[rocket::async_trait] #[rocket::async_trait]
impl<'r> FromRequest<'r> for User { impl<'r> FromRequest<'r> for User {
type Error = authifier::Error; type Error = Error;
async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> { async fn from_request(request: &'r Request<'_>) -> request::Outcome<Self, Self::Error> {
let user: &Option<User> = request let user: &Option<User> = request
@@ -38,7 +38,7 @@ impl<'r> FromRequest<'r> for User {
if let Some(user) = user { if let Some(user) = user {
Outcome::Success(user.clone()) Outcome::Success(user.clone())
} else { } else {
Outcome::Error((Status::Unauthorized, authifier::Error::InvalidSession)) Outcome::Error((Status::Unauthorized, create_error!(InvalidSession)))
} }
} }
} }

View File

@@ -105,7 +105,11 @@ pub async fn handle_ack_event(
if mentions_acked > 0 { if mentions_acked > 0 {
if let Err(err) = amqp if let Err(err) = amqp
.ack_message(user.to_string(), channel.to_string(), id.to_owned()) .ack_notification_message(
user.to_string(),
channel.to_string(),
id.to_owned(),
)
.await .await
{ {
revolt_config::capture_error(&err); revolt_config::capture_error(&err);
@@ -192,9 +196,7 @@ pub async fn handle_ack_event(
.expect("Failed to fetch channel from db"); .expect("Failed to fetch channel from db");
if let TextChannel { server, .. } = channel { if let TextChannel { server, .. } = channel {
if let Err(err) = if let Err(err) = amqp.mass_mention_message_sent(server, mass_mentions).await {
amqp.mass_mention_message_sent(server, mass_mentions).await
{
revolt_config::capture_error(&err); revolt_config::capture_error(&err);
} }
} else { } else {
@@ -303,6 +305,6 @@ pub async fn worker(db: Database, amqp: AMQP) {
} }
// Sleep for an arbitrary amount of time. // Sleep for an arbitrary amount of time.
async_std::task::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
} }

View File

@@ -1,29 +0,0 @@
use async_std::channel::{unbounded, Receiver, Sender};
use authifier::AuthifierEvent;
use once_cell::sync::Lazy;
use crate::events::client::EventV1;
static Q: Lazy<(Sender<AuthifierEvent>, Receiver<AuthifierEvent>)> = Lazy::new(unbounded);
/// Get sender
pub fn sender() -> Sender<AuthifierEvent> {
Q.0.clone()
}
/// Start a new worker
pub async fn worker() {
loop {
let event = Q.1.recv().await.unwrap();
match &event {
AuthifierEvent::CreateSession { .. } | AuthifierEvent::CreateAccount { .. } => {
EventV1::Auth(event).global().await
}
AuthifierEvent::DeleteSession { user_id, .. }
| AuthifierEvent::DeleteAllSessions { user_id, .. } => {
let id = user_id.to_string();
EventV1::Auth(event).private(id).await
}
}
}
}

View File

@@ -82,6 +82,6 @@ pub async fn worker(db: Database) {
} }
// Sleep for an arbitrary amount of time. // Sleep for an arbitrary amount of time.
async_std::task::sleep(Duration::from_secs(1)).await; tokio::time::sleep(Duration::from_secs(1)).await;
} }
} }

View File

@@ -2,20 +2,17 @@
use crate::{Database, AMQP}; use crate::{Database, AMQP};
use async_std::task; use tokio::task;
use std::time::Instant; use std::time::Instant;
const WORKER_COUNT: usize = 5; const WORKER_COUNT: usize = 5;
pub mod ack; pub mod ack;
pub mod authifier_relay;
pub mod last_message_id; pub mod last_message_id;
pub mod process_embeds; pub mod process_embeds;
/// Spawn background workers /// Spawn background workers
pub fn start_workers(db: Database, amqp: AMQP) { pub fn start_workers(db: Database, amqp: AMQP) {
task::spawn(authifier_relay::worker());
for _ in 0..WORKER_COUNT { for _ in 0..WORKER_COUNT {
task::spawn(ack::worker(db.clone(), amqp.clone())); task::spawn(ack::worker(db.clone(), amqp.clone()));
task::spawn(last_message_id::worker(db.clone())); task::spawn(last_message_id::worker(db.clone()));

View File

@@ -7,11 +7,11 @@ use revolt_config::config;
use revolt_result::Result; use revolt_result::Result;
use async_lock::Semaphore; use async_lock::Semaphore;
use async_std::task::spawn;
use deadqueue::limited::Queue; use deadqueue::limited::Queue;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use revolt_models::v0::Embed; use revolt_models::v0::Embed;
use std::{collections::HashSet, sync::Arc}; use std::{collections::HashSet, sync::Arc};
use tokio::task::spawn;
use isahc::prelude::*; use isahc::prelude::*;
@@ -161,6 +161,7 @@ pub async fn generate(
.await .await
.into_iter() .into_iter()
.flatten() .flatten()
.flatten()
.collect::<Vec<Embed>>(); .collect::<Vec<Embed>>();
// Prevent database update when no embeds are found. // Prevent database update when no embeds are found.

View File

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

View File

@@ -190,7 +190,7 @@ impl From<crate::Channel> for Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice, voice,
slowmode slowmode,
} => Channel::TextChannel { } => Channel::TextChannel {
id, id,
server, server,
@@ -202,7 +202,7 @@ impl From<crate::Channel> for Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice: voice.map(|voice| voice.into()), voice: voice.map(|voice| voice.into()),
slowmode slowmode,
}, },
} }
} }
@@ -256,7 +256,7 @@ impl From<Channel> for crate::Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice, voice,
slowmode slowmode,
} => crate::Channel::TextChannel { } => crate::Channel::TextChannel {
id, id,
server, server,
@@ -268,7 +268,7 @@ impl From<Channel> for crate::Channel {
role_permissions, role_permissions,
nsfw, nsfw,
voice: voice.map(|voice| voice.into()), voice: voice.map(|voice| voice.into()),
slowmode slowmode,
}, },
} }
} }
@@ -307,7 +307,7 @@ impl From<PartialChannel> for crate::PartialChannel {
default_permissions: value.default_permissions, default_permissions: value.default_permissions,
last_message_id: value.last_message_id, last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into()), voice: value.voice.map(|voice| voice.into()),
slowmode: value.slowmode slowmode: value.slowmode,
} }
} }
} }
@@ -631,6 +631,7 @@ impl From<crate::Member> for Member {
id: value.id.into(), id: value.id.into(),
joined_at: value.joined_at, joined_at: value.joined_at,
nickname: value.nickname, nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
@@ -646,6 +647,7 @@ impl From<Member> for crate::Member {
id: value.id.into(), id: value.id.into(),
joined_at: value.joined_at, joined_at: value.joined_at,
nickname: value.nickname, nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
@@ -661,6 +663,7 @@ impl From<crate::PartialMember> for PartialMember {
id: value.id.map(|id| id.into()), id: value.id.map(|id| id.into()),
joined_at: value.joined_at, joined_at: value.joined_at,
nickname: value.nickname, nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
@@ -676,6 +679,7 @@ impl From<PartialMember> for crate::PartialMember {
id: value.id.map(|id| id.into()), id: value.id.map(|id| id.into()),
joined_at: value.joined_at, joined_at: value.joined_at,
nickname: value.nickname, nickname: value.nickname,
pronouns: value.pronouns,
avatar: value.avatar.map(|f| f.into()), avatar: value.avatar.map(|f| f.into()),
roles: value.roles, roles: value.roles,
timeout: value.timeout, timeout: value.timeout,
@@ -708,6 +712,7 @@ impl From<crate::FieldsMember> for FieldsMember {
match value { match value {
crate::FieldsMember::Avatar => FieldsMember::Avatar, crate::FieldsMember::Avatar => FieldsMember::Avatar,
crate::FieldsMember::Nickname => FieldsMember::Nickname, crate::FieldsMember::Nickname => FieldsMember::Nickname,
crate::FieldsMember::Pronouns => FieldsMember::Pronouns,
crate::FieldsMember::Roles => FieldsMember::Roles, crate::FieldsMember::Roles => FieldsMember::Roles,
crate::FieldsMember::Timeout => FieldsMember::Timeout, crate::FieldsMember::Timeout => FieldsMember::Timeout,
crate::FieldsMember::CanReceive => FieldsMember::CanReceive, crate::FieldsMember::CanReceive => FieldsMember::CanReceive,
@@ -723,6 +728,7 @@ impl From<FieldsMember> for crate::FieldsMember {
match value { match value {
FieldsMember::Avatar => crate::FieldsMember::Avatar, FieldsMember::Avatar => crate::FieldsMember::Avatar,
FieldsMember::Nickname => crate::FieldsMember::Nickname, FieldsMember::Nickname => crate::FieldsMember::Nickname,
FieldsMember::Pronouns => crate::FieldsMember::Pronouns,
FieldsMember::Roles => crate::FieldsMember::Roles, FieldsMember::Roles => crate::FieldsMember::Roles,
FieldsMember::Timeout => crate::FieldsMember::Timeout, FieldsMember::Timeout => crate::FieldsMember::Timeout,
FieldsMember::CanReceive => crate::FieldsMember::CanReceive, FieldsMember::CanReceive => crate::FieldsMember::CanReceive,
@@ -926,6 +932,7 @@ impl From<crate::Role> for Role {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -939,6 +946,7 @@ impl From<Role> for crate::Role {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -952,6 +960,7 @@ impl From<crate::PartialRole> for PartialRole {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -965,6 +974,7 @@ impl From<PartialRole> for crate::PartialRole {
colour: value.colour, colour: value.colour,
hoist: value.hoist, hoist: value.hoist,
rank: value.rank, rank: value.rank,
icon: value.icon.map(|f| f.into()),
} }
} }
} }
@@ -973,6 +983,7 @@ impl From<crate::FieldsRole> for FieldsRole {
fn from(value: crate::FieldsRole) -> Self { fn from(value: crate::FieldsRole) -> Self {
match value { match value {
crate::FieldsRole::Colour => FieldsRole::Colour, crate::FieldsRole::Colour => FieldsRole::Colour,
crate::FieldsRole::Icon => FieldsRole::Icon,
} }
} }
} }
@@ -981,6 +992,7 @@ impl From<FieldsRole> for crate::FieldsRole {
fn from(value: FieldsRole) -> Self { fn from(value: FieldsRole) -> Self {
match value { match value {
FieldsRole::Colour => crate::FieldsRole::Colour, FieldsRole::Colour => crate::FieldsRole::Colour,
FieldsRole::Icon => crate::FieldsRole::Icon,
} }
} }
} }
@@ -1026,6 +1038,7 @@ impl crate::User {
username: self.username, username: self.username,
discriminator: self.discriminator, discriminator: self.discriminator,
display_name: self.display_name, display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()), avatar: self.avatar.map(|file| file.into()),
relations: if let Some(crate::User { id, .. }) = perspective { relations: if let Some(crate::User { id, .. }) = perspective {
if id == &self.id { if id == &self.id {
@@ -1102,6 +1115,7 @@ impl crate::User {
username: self.username, username: self.username,
discriminator: self.discriminator, discriminator: self.discriminator,
display_name: self.display_name, display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()), avatar: self.avatar.map(|file| file.into()),
relations: vec![], relations: vec![],
badges, badges,
@@ -1135,6 +1149,7 @@ impl crate::User {
username: self.username, username: self.username,
discriminator: self.discriminator, discriminator: self.discriminator,
display_name: self.display_name, display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()), avatar: self.avatar.map(|file| file.into()),
relations: vec![], relations: vec![],
badges, badges,
@@ -1162,6 +1177,7 @@ impl crate::User {
username: self.username, username: self.username,
discriminator: self.discriminator, discriminator: self.discriminator,
display_name: self.display_name, display_name: self.display_name,
pronouns: self.pronouns,
avatar: self.avatar.map(|file| file.into()), avatar: self.avatar.map(|file| file.into()),
relations: self relations: self
.relations .relations
@@ -1205,6 +1221,7 @@ impl From<User> for crate::User {
username: value.username, username: value.username,
discriminator: value.discriminator, discriminator: value.discriminator,
display_name: value.display_name, display_name: value.display_name,
pronouns: value.pronouns,
avatar: value.avatar.map(Into::into), avatar: value.avatar.map(Into::into),
relations: None, relations: None,
badges: Some(value.badges as i32), badges: Some(value.badges as i32),
@@ -1225,6 +1242,7 @@ impl From<crate::PartialUser> for PartialUser {
username: value.username, username: value.username,
discriminator: value.discriminator, discriminator: value.discriminator,
display_name: value.display_name, display_name: value.display_name,
pronouns: value.pronouns,
avatar: value.avatar.map(|file| file.into()), avatar: value.avatar.map(|file| file.into()),
relations: value.relations.map(|relationships| { relations: value.relations.map(|relationships| {
relationships relationships
@@ -1253,6 +1271,7 @@ impl From<FieldsUser> for crate::FieldsUser {
FieldsUser::StatusPresence => crate::FieldsUser::StatusPresence, FieldsUser::StatusPresence => crate::FieldsUser::StatusPresence,
FieldsUser::StatusText => crate::FieldsUser::StatusText, FieldsUser::StatusText => crate::FieldsUser::StatusText,
FieldsUser::DisplayName => crate::FieldsUser::DisplayName, FieldsUser::DisplayName => crate::FieldsUser::DisplayName,
FieldsUser::Pronouns => crate::FieldsUser::Pronouns,
FieldsUser::Internal => crate::FieldsUser::None, FieldsUser::Internal => crate::FieldsUser::None,
} }
@@ -1268,6 +1287,7 @@ impl From<crate::FieldsUser> for FieldsUser {
crate::FieldsUser::StatusPresence => FieldsUser::StatusPresence, crate::FieldsUser::StatusPresence => FieldsUser::StatusPresence,
crate::FieldsUser::StatusText => FieldsUser::StatusText, crate::FieldsUser::StatusText => FieldsUser::StatusText,
crate::FieldsUser::DisplayName => FieldsUser::DisplayName, crate::FieldsUser::DisplayName => FieldsUser::DisplayName,
crate::FieldsUser::Pronouns => FieldsUser::Pronouns,
crate::FieldsUser::Suspension => FieldsUser::Internal, crate::FieldsUser::Suspension => FieldsUser::Internal,
crate::FieldsUser::None => FieldsUser::Internal, crate::FieldsUser::None => FieldsUser::Internal,
@@ -1416,3 +1436,92 @@ impl From<crate::VoiceInformation> for VoiceInformation {
} }
} }
} }
impl From<crate::Account> for AccountInfo {
fn from(item: crate::Account) -> Self {
AccountInfo {
id: item.id,
email: item.email,
}
}
}
impl From<crate::MFATicket> for MFATicket {
fn from(value: crate::MFATicket) -> Self {
MFATicket {
id: value.id,
account_id: value.account_id,
token: value.token,
validated: value.validated,
authorised: value.authorised,
last_totp_code: value.last_totp_code,
}
}
}
impl From<crate::MultiFactorAuthentication> for MultiFactorStatus {
fn from(item: crate::MultiFactorAuthentication) -> Self {
MultiFactorStatus {
// email_otp: item.enable_email_otp,
// trusted_handover: item.enable_trusted_handover,
// email_mfa: item.enable_email_mfa,
totp_mfa: !item.totp_token.is_disabled(),
// security_key_mfa: item.security_key_token.is_some(),
recovery_active: !item.recovery_codes.is_empty(),
..Default::default()
}
}
}
impl From<crate::MFAMethod> for MFAMethod {
fn from(value: crate::MFAMethod) -> Self {
match value {
crate::MFAMethod::Password => MFAMethod::Password,
crate::MFAMethod::Recovery => MFAMethod::Recovery,
crate::MFAMethod::Totp => MFAMethod::Totp,
}
}
}
impl From<crate::Session> for SessionInfo {
fn from(item: crate::Session) -> Self {
SessionInfo {
id: item.id,
name: item.name,
}
}
}
impl From<crate::Session> for Session {
fn from(value: crate::Session) -> Self {
Session {
id: value.id,
user_id: value.user_id,
token: value.token,
name: value.name,
last_seen: value.last_seen,
origin: value.origin,
subscription: value.subscription.map(Into::into),
}
}
}
impl From<crate::WebPushSubscription> for WebPushSubscription {
fn from(value: crate::WebPushSubscription) -> Self {
WebPushSubscription {
endpoint: value.endpoint,
p256dh: value.p256dh,
auth: value.auth,
}
}
}
impl From<WebPushSubscription> for crate::WebPushSubscription {
fn from(value: WebPushSubscription) -> Self {
crate::WebPushSubscription {
endpoint: value.endpoint,
p256dh: value.p256dh,
auth: value.auth,
}
}
}

View File

@@ -0,0 +1,42 @@
use reqwest::Client;
use revolt_config::config;
use revolt_result::Result;
use std::sync::LazyLock;
static CLIENT: LazyLock<Client> = LazyLock::new(Client::new);
#[derive(Serialize, Deserialize)]
struct CaptchaResponse {
success: bool,
}
pub async fn check_captcha(token: Option<&str>) -> Result<()> {
let config = config().await;
if !config.api.security.captcha.hcaptcha_key.is_empty() {
let Some(token) = token else {
return Err(create_error!(CaptchaFailed));
};
let response = CLIENT
.post("https://hcaptcha.com/siteverify")
.form(&[
("secret", config.api.security.captcha.hcaptcha_key.as_str()),
("response", token),
])
.send()
.await
.map_err(|_| create_error!(CaptchaFailed))?
.json::<CaptchaResponse>()
.await
.map_err(|_| create_error!(CaptchaFailed))?;
if response.success {
Ok(())
} else {
Err(create_error!(CaptchaFailed))
}
} else {
Ok(())
}
}

View File

@@ -44,4 +44,20 @@ impl<T: for<'d> Deserialize<'d> + Clone> ChunkedDatabaseGenerator<T> {
} }
} }
} }
pub async fn next_n(&mut self, n: usize) -> Result<Option<Vec<T>>> {
let mut docs = Vec::new();
while docs.len() < n {
if let Some(doc) = self.next().await? {
docs.push(doc);
} else if docs.is_empty() {
return Ok(None);
} else {
break;
}
}
Ok(Some(docs))
}
} }

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