Merge remote-tracking branch 'origin/main' into livekit

This commit is contained in:
Zomatree
2025-01-26 20:36:40 +00:00
227 changed files with 16549 additions and 2943 deletions

View File

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

37
.github/workflows/book.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
name: Build documentation
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
contents: write # To push a branch
pages: write # To push to a GitHub Pages site
id-token: write # To update the deployment status
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Install latest mdbook
run: |
tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name')
url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz"
mkdir mdbook
curl -sSL $url | tar -xz --directory=./mdbook
echo `pwd`/mdbook >> $GITHUB_PATH
- name: Build Book
run: |
cd doc
mdbook build
- name: Setup Pages
uses: actions/configure-pages@v4
- name: Upload artifact
uses: actions/upload-pages-artifact@v3
with:
path: "doc/book"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

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

View File

@@ -2,14 +2,15 @@ name: Docker Test & Publish
on: on:
push: push:
branches: # branches:
- "main" # - "main"
tags: tags:
- "*" - "*"
paths-ignore: paths-ignore:
- ".github/**" - ".github/**"
- "!.github/workflows/docker.yml" - "!.github/workflows/docker.yml"
- ".vscode/**" - ".vscode/**"
- "doc/**"
- ".gitignore" - ".gitignore"
- "LICENSE" - "LICENSE"
- "README" - "README"
@@ -60,7 +61,7 @@ jobs:
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
strategy: strategy:
matrix: matrix:
project: [delta, bonfire] project: [delta, bonfire, autumn, january, pushd]
name: Build ${{ matrix.project }} image name: Build ${{ matrix.project }} image
steps: steps:
# Configure build environment # Configure build environment
@@ -97,6 +98,18 @@ jobs:
"bonfire": { "bonfire": {
"path": "crates/bonfire", "path": "crates/bonfire",
"tag": "${{ github.repository_owner }}/bonfire" "tag": "${{ github.repository_owner }}/bonfire"
},
"autumn": {
"path": "crates/services/autumn",
"tag": "${{ github.repository_owner }}/autumn"
},
"january": {
"path": "crates/services/january",
"tag": "${{ github.repository_owner }}/january"
},
"pushd": {
"path": "crates/daemons/pushd",
"tag": "${{ github.repository_owner }}/pushd"
} }
} }
export_to: output export_to: output

View File

@@ -2,6 +2,14 @@ name: Rust build, test, and generate specification
on: on:
push: push:
paths-ignore:
- ".github/**"
- "!.github/workflows/rust.yaml"
- ".vscode/**"
- "doc/**"
- ".gitignore"
- "LICENSE"
- "README"
pull_request: pull_request:
branches: [main] branches: [main]
@@ -33,11 +41,7 @@ jobs:
- name: Run services in background - name: Run services in background
run: | run: |
docker-compose -f docker-compose.db.yml up -d docker compose -f compose.yml up -d
- name: Copy .env.example
run: |
cp .env.example .env
- name: Run cargo test - name: Run cargo test
env: env:
@@ -63,7 +67,7 @@ jobs:
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'
uses: nev7n/wait_for_response@v1 uses: nev7n/wait_for_response@v1
with: with:
url: "http://localhost:8000/" url: "http://localhost:14702/"
- name: Checkout API repository - name: Checkout API repository
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'
@@ -75,7 +79,7 @@ jobs:
- name: Download OpenAPI specification - name: Download OpenAPI specification
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'
run: curl http://localhost:8000/openapi.json -o api/OpenAPI.json run: curl http://localhost:14702/openapi.json -o api/OpenAPI.json
- name: Commit changes - name: Commit changes
if: github.event_name != 'pull_request' && github.ref_name == 'main' if: github.event_name != 'pull_request' && github.ref_name == 'main'

4
.gitignore vendored
View File

@@ -1,5 +1,5 @@
Rocket.toml Rocket.toml
Revolt.toml Revolt.*.toml
target target
.data .data
@@ -9,3 +9,5 @@ target
.DS_Store .DS_Store
livekit.yml livekit.yml
.idea
start

View File

@@ -2,5 +2,5 @@
"editor.formatOnSave": true, "editor.formatOnSave": true,
"rust-analyzer.checkOnSave.command": "clippy", "rust-analyzer.checkOnSave.command": "clippy",
"nixEnvSelector.suggestion": false, "nixEnvSelector.suggestion": false,
"nixEnvSelector.nixFile": "${workspaceRoot}/default.nix" "nixEnvSelector.nixFile": "${workspaceFolder}/default.nix"
} }

5369
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,22 @@
[workspace] [workspace]
resolver = "2" resolver = "2"
members = ["crates/delta", "crates/bonfire", "crates/voice-ingress", "crates/core/*"]
members = [
"crates/delta",
"crates/bonfire",
"crates/core/*",
"crates/services/*",
"crates/bindings/*",
"crates/daemons/pushd",
"crates/daemons/voice-ingress"
]
[patch.crates-io] [patch.crates-io]
# mobc-redis = { git = "https://github.com/insertish/mobc", rev = "8b880bb59f2ba80b4c7bc40c649c113d8857a186" }
redis22 = { package = "redis", version = "0.22.3", git = "https://github.com/revoltchat/redis-rs", rev = "1a41faf356fd21aebba71cea7eb7eb2653e5f0ef" } redis22 = { package = "redis", version = "0.22.3", git = "https://github.com/revoltchat/redis-rs", rev = "1a41faf356fd21aebba71cea7eb7eb2653e5f0ef" }
redis23 = { package = "redis", version = "0.23.1", git = "https://github.com/revoltchat/redis-rs", rev = "f8ca28ab85da59d2ccde526b4d2fb390eff5a5f9" } redis23 = { package = "redis", version = "0.23.1", git = "https://github.com/revoltchat/redis-rs", rev = "f8ca28ab85da59d2ccde526b4d2fb390eff5a5f9" }
# authifier = { package = "authifier", version = "1.0.8", path = "../authifier/crates/authifier" } # authifier = { package = "authifier", version = "1.0.8", path = "../authifier/crates/authifier" }
# rocket_authifier = { package = "rocket_authifier", version = "1.0.8", path = "../authifier/crates/rocket_authifier" } # rocket_authifier = { package = "rocket_authifier", version = "1.0.8", path = "../authifier/crates/rocket_authifier" }
rocket = { git = "https://github.com/rwf2/Rocket/", rev = "4dcd928" } rocket = { git = "https://github.com/rwf2/Rocket/", rev = "4dcd928" }
[profile.release]
lto = true

View File

@@ -1,5 +1,5 @@
# Build Stage # Build Stage
FROM --platform="${BUILDPLATFORM}" rust:1.70.0-slim FROM --platform="${BUILDPLATFORM}" rust:1.77.2-slim-bookworm
USER 0:0 USER 0:0
WORKDIR /home/rust/src WORKDIR /home/rust/src
@@ -17,14 +17,19 @@ RUN sh /tmp/build-image-layer.sh tools
# Build all dependencies # Build all dependencies
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/bindings/node/Cargo.toml ./crates/bindings/node/
COPY crates/bonfire/Cargo.toml ./crates/bonfire/ COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/ COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/core/config/Cargo.toml ./crates/core/config/ COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/ COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/files/Cargo.toml ./crates/core/files/
COPY crates/core/models/Cargo.toml ./crates/core/models/ COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/ COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/ COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/ COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
RUN sh /tmp/build-image-layer.sh deps RUN sh /tmp/build-image-layer.sh deps
# Build all apps # Build all apps

View File

@@ -1,5 +1,5 @@
# Build Stage # Build Stage
FROM rust:1.70.0-slim FROM rust:1.77.2-slim-bookworm
USER 0:0 USER 0:0
WORKDIR /home/rust/src WORKDIR /home/rust/src
@@ -13,14 +13,19 @@ COPY scripts/build-image-layer.sh /tmp/
# Build all dependencies # Build all dependencies
COPY Cargo.toml Cargo.lock ./ COPY Cargo.toml Cargo.lock ./
COPY crates/bindings/node/Cargo.toml ./crates/bindings/node/
COPY crates/bonfire/Cargo.toml ./crates/bonfire/ COPY crates/bonfire/Cargo.toml ./crates/bonfire/
COPY crates/delta/Cargo.toml ./crates/delta/ COPY crates/delta/Cargo.toml ./crates/delta/
COPY crates/core/config/Cargo.toml ./crates/core/config/ COPY crates/core/config/Cargo.toml ./crates/core/config/
COPY crates/core/database/Cargo.toml ./crates/core/database/ COPY crates/core/database/Cargo.toml ./crates/core/database/
COPY crates/core/files/Cargo.toml ./crates/core/files/
COPY crates/core/models/Cargo.toml ./crates/core/models/ COPY crates/core/models/Cargo.toml ./crates/core/models/
COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/ COPY crates/core/permissions/Cargo.toml ./crates/core/permissions/
COPY crates/core/presence/Cargo.toml ./crates/core/presence/ COPY crates/core/presence/Cargo.toml ./crates/core/presence/
COPY crates/core/result/Cargo.toml ./crates/core/result/ COPY crates/core/result/Cargo.toml ./crates/core/result/
COPY crates/services/autumn/Cargo.toml ./crates/services/autumn/
COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
RUN sh /tmp/build-image-layer.sh deps RUN sh /tmp/build-image-layer.sh deps
# Build all apps # Build all apps

View File

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

123
README.md
View File

@@ -1,26 +1,47 @@
# Revolt Backend <div align="center">
<h1>
Revolt Backend
This is a monorepo for the Revolt backend. [![Stars](https://img.shields.io/github/stars/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/stargazers)
[![Forks](https://img.shields.io/github/forks/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/pulls)
[![Issues](https://img.shields.io/github/issues/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/issues)
[![Contributors](https://img.shields.io/github/contributors/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/graphs/contributors)
[![License](https://img.shields.io/github/license/revoltchat/backend?style=flat-square&logoColor=white)](https://github.com/revoltchat/backend/blob/main/LICENSE)
</h1>
The services and libraries that power the Revolt service.<br/>
<br/>
| Crate | Path | Description | | Crate | Path | Description | |
| ------------------ | -------------------------------------------------- | ----------------------------- | | ------------------ | -------------------------------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `core/config` | [crates/core/config](crates/core/config) | Core: Configuration | | `core/config` | [crates/core/config](crates/core/config) | Core: Configuration | ![Crates.io Version](https://img.shields.io/crates/v/revolt-config) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-config) ![Crates.io Version](https://img.shields.io/crates/size/revolt-config) ![Crates.io License](https://img.shields.io/crates/l/revolt-config) |
| `core/database` | [crates/core/database](crates/core/database) | Core: Database Implementation | | `core/database` | [crates/core/database](crates/core/database) | Core: Database Implementation | ![Crates.io Version](https://img.shields.io/crates/v/revolt-database) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-database) ![Crates.io Version](https://img.shields.io/crates/size/revolt-database) ![Crates.io License](https://img.shields.io/crates/l/revolt-database) |
| `core/models` | [crates/core/models](crates/core/models) | Core: API Models | | `core/files` | [crates/core/files](crates/core/files) | Core: S3 and encryption subroutines | ![Crates.io Version](https://img.shields.io/crates/v/revolt-files) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-files) ![Crates.io Version](https://img.shields.io/crates/size/revolt-files) ![Crates.io License](https://img.shields.io/crates/l/revolt-files) |
| `core/permissions` | [crates/core/permissions](crates/core/permissions) | Core: Permission Logic | | `core/models` | [crates/core/models](crates/core/models) | Core: API Models | ![Crates.io Version](https://img.shields.io/crates/v/revolt-models) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-models) ![Crates.io Version](https://img.shields.io/crates/size/revolt-models) ![Crates.io License](https://img.shields.io/crates/l/revolt-models) |
| `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence | | `core/permissions` | [crates/core/permissions](crates/core/permissions) | Core: Permission Logic | ![Crates.io Version](https://img.shields.io/crates/v/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-permissions) ![Crates.io Version](https://img.shields.io/crates/size/revolt-permissions) ![Crates.io License](https://img.shields.io/crates/l/revolt-permissions) |
| `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types | | `core/presence` | [crates/core/presence](crates/core/presence) | Core: User Presence | ![Crates.io Version](https://img.shields.io/crates/v/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-presence) ![Crates.io Version](https://img.shields.io/crates/size/revolt-presence) ![Crates.io License](https://img.shields.io/crates/l/revolt-presence) |
| `delta` | [crates/delta](crates/delta) | REST API server | | `core/result` | [crates/core/result](crates/core/result) | Core: Result and Error types | ![Crates.io Version](https://img.shields.io/crates/v/revolt-result) ![Crates.io Version](https://img.shields.io/crates/msrv/revolt-result) ![Crates.io Version](https://img.shields.io/crates/size/revolt-result) ![Crates.io License](https://img.shields.io/crates/l/revolt-result) |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server | | `delta` | [crates/delta](crates/delta) | REST API server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `bonfire` | [crates/bonfire](crates/bonfire) | WebSocket events server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/january` | [crates/services/january](crates/services/january) | Proxy server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `services/autumn` | [crates/services/autumn](crates/services/autumn) | File server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `bindings/node` | [crates/bindings/node](crates/bindings/node) | Node.js bindings for the Revolt software | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
| `daemons/pushd` | [crates/daemons/pushd](crates/daemons/pushd) | Push notification daemon server | ![License](https://img.shields.io/badge/license-AGPL--3.0--or--later-blue) |
Note: `january`, `autumn`, and `vortex` are yet to be moved into this monorepo. </div>
<br/>
## Minimum Supported Rust Version ## Minimum Supported Rust Version
Rust 1.70 or higher. Rust 1.76 or higher.
> [!CAUTION]
> The events server has a significant performance regression between Rust 1.77.2 and 1.78.0 onwards, see [issue #341](https://github.com/revoltchat/backend/issues/341).
## Development Guide ## Development Guide
Before contributing, make yourself familiar with [our contribution guidelines](https://developers.revolt.chat/contrib.html) and the [technical documentation for this project](https://revoltchat.github.io/backend/).
Before getting started, you'll want to install: Before getting started, you'll want to install:
- Rust toolchain (rustup recommended) - Rust toolchain (rustup recommended)
@@ -31,6 +52,21 @@ Before getting started, you'll want to install:
> A **default.nix** is available for Nix users! > A **default.nix** is available for Nix users!
> Just run `nix-shell` and continue. > Just run `nix-shell` and continue.
As a heads-up, the development environment uses the following ports:
| Service | Port |
| ------------------------- | :------------: |
| MongoDB | 27017 |
| Redis | 6379 |
| MinIO | 14009 |
| Maildev | 14025<br>14080 |
| Revolt Web App | 14701 |
| RabbitMQ | 5672<br>15672 |
| `crates/delta` | 14702 |
| `crates/bonfire` | 14703 |
| `crates/services/autumn` | 14704 |
| `crates/services/january` | 14705 |
Now you can clone and build the project: Now you can clone and build the project:
```bash ```bash
@@ -39,26 +75,45 @@ cd revolt-backend
cargo build cargo build
``` ```
If you want to run the API and event servers: A default configuration `Revolt.toml` is present in this project that is suited for development.
If you'd like to change anything, create a `Revolt.overrides.toml` file and specify relevant variables.
> [!TIP]
> Use Sentry to catch unexpected service errors:
>
> ```toml
> # Revolt.overrides.toml
> [sentry]
> api = "https://abc@your.sentry/1"
> events = "https://abc@your.sentry/1"
> files = "https://abc@your.sentry/1"
> proxy = "https://abc@your.sentry/1"
> ```
Then continue:
```bash ```bash
# create environment file (will be deprecated in future)
cp .env.example .env
# (optionally) copy the default configuration file
cp crates/core/config/Revolt.toml Revolt.toml
# configure as necessary...
# start other necessary services # start other necessary services
docker compose up -d docker compose up -d
# run everything together
./scripts/start.sh
# .. or individually
# run the API server # run the API server
cargo run --bin revolt-delta cargo run --bin revolt-delta
# run the events server # run the events server
cargo run --bin revolt-bonfire cargo run --bin revolt-bonfire
# run the file server
cargo run --bin revolt-autumn
# run the proxy server
cargo run --bin revolt-january
# run the push daemon (not usually needed in regular development)
cargo run --bin revolt-pushd
# hint: # hint:
# mold -run <cargo build, cargo run, etc...> # mold -run <cargo build, cargo run, etc...>
# mold -run ./scripts/start.sh
``` ```
You can start a web client by doing the following: You can start a web client by doing the following:
@@ -72,10 +127,13 @@ git clone --recursive https://github.com/revoltchat/revite
cd revite cd revite
yarn yarn
yarn build:deps yarn build:deps
yarn dev --port 3001 echo "VITE_API_URL=http://local.revolt.chat:14702" > .env.local
yarn dev --port 14701
``` ```
Then go to https://local.revolt.chat:3001 Then go to http://local.revolt.chat:14701 to create an account/login.
When signing up, go to http://localhost:14080 to find confirmation/password reset emails.
## Deployment Guide ## Deployment Guide
@@ -105,6 +163,23 @@ Tag and push a new release by running:
just release just release
``` ```
If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/revoltchat/backend/releases/new) to create a changelog.
## Testing
First, start the required services:
```sh
docker compose -f docker-compose.db.yml up -d
```
Now run tests for whichever database:
```sh
TEST_DB=REFERENCE cargo nextest run
TEST_DB=MONGODB cargo nextest run
```
## License ## License
The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE). The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE).

61
Revolt.toml Normal file
View File

@@ -0,0 +1,61 @@
# ⚠️ This configuration is intended for development environment.
# If you'd like to override anything, create a Revolt.override.toml
[database]
# MongoDB connection URL
# Defaults to the container name specified in self-hosted
mongodb = "mongodb://127.0.0.1:14017"
# Redis connection URL
# Defaults to the container name specified in self-hosted
redis = "redis://127.0.0.1:14079/"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat:14701"
api = "http://local.revolt.chat:14702"
events = "ws://local.revolt.chat:14703"
autumn = "http://local.revolt.chat:14704"
january = "http://local.revolt.chat:14705"
livekit = "ws://local.revolt.chat:14706"
voso_legacy = ""
voso_legacy_ws = ""
[api]
[api.smtp]
# Email server configuration for verification
# Defaults to no email verification (host field is empty)
host = "localhost"
username = "smtp"
password = "smtp"
from_address = "development@revolt.chat"
reply_to = "support@revolt.chat"
port = 14025
use_tls = false
[api.livekit]
url = "ws://local.revolt.chat:14706"
key = ""
secret = ""
[files.s3]
# S3 protocol endpoint
endpoint = "http://127.0.0.1:14009"
# S3 region name
region = "minio"
# S3 protocol key ID
access_key_id = "minioautumn"
# S3 protocol access key
secret_access_key = "minioautumn"
# Bucket to upload to by default
default_bucket = "revolt-uploads"
[rabbit]
host = "rabbit.backend.orb.local"
port = 5672
username = "rabbituser"
password = "rabbitpass"

66
compose.yml Normal file
View File

@@ -0,0 +1,66 @@
services:
# Redis
redis:
image: eqalpha/keydb
ports:
- "6379:6379"
# MongoDB
database:
image: mongo
ports:
- "27017:27017"
volumes:
- ./.data/db:/data/db
# MinIO
minio:
image: minio/minio
command: server /data
environment:
MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn
volumes:
- ./.data/minio:/data
ports:
- "14009:9000"
- "14010:9001"
restart: always
# Create buckets for minio.
createbuckets:
image: minio/mc
depends_on:
- minio
entrypoint: >
/bin/sh -c "while ! /usr/bin/mc ready minio; do
/usr/bin/mc config host add minio http://minio:9000 minioautumn minioautumn;
echo 'Waiting minio...' && sleep 1;
done; /usr/bin/mc mb minio/revolt-uploads; exit 0;"
# Rabbit
rabbit:
image: rabbitmq:3-management
environment:
RABBITMQ_DEFAULT_USER: rabbituser
RABBITMQ_DEFAULT_PASS: rabbitpass
volumes:
- ./.data/rabbit:/var/lib/rabbitmq
#- ./rabbit_plugins:/opt/rabbitmq/plugins/
#- ./rabbit_enabled_plugins:/etc/rabbitmq/enabled_plugins
# uncomment this if you need to enable other plugins
ports:
- "5672:5672"
- "15672:15672" # management UI, for development
# Mock SMTP server
maildev:
image: soulteary/maildev
ports:
- "14025:25"
- "14080:8080"
environment:
MAILDEV_SMTP_PORT: 25
MAILDEV_WEB_PORT: 8080
MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp

7
crates/bindings/node/.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
target
index.node
**/node_modules
**/.DS_Store
npm-debug.log*
cargo.log
cross.log

View File

@@ -0,0 +1,25 @@
[package]
name = "revolt-nodejs-bindings"
version = "0.8.1"
description = "Node.js bindings for the Revolt software"
authors = ["Paul Makles <me@insrt.uk>"]
license = "MIT"
edition = "2021"
exclude = ["index.node"]
[lib]
crate-type = ["cdylib"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
neon = "1.0.0"
neon-serde4 = "1.0.0"
serde = { version = "1", features = ["derive"] }
async-std = "1.12.0"
revolt-config = { version = "0.8.1", path = "../../core/config" }
revolt-result = { version = "0.8.1", path = "../../core/result" }
revolt-database = { version = "0.8.1", path = "../../core/database" }

View File

@@ -0,0 +1,92 @@
# revolt.node
**revolt.node:** Node.js bindings for the Revolt software
This project was bootstrapped by [create-neon](https://www.npmjs.com/package/create-neon).
## Building revolt.node
Building revolt.node requires a [supported version of Node and Rust](https://github.com/neon-bindings/neon#platform-support).
To run the build, run:
```sh
$ npm run build
```
This command uses the [@neon-rs/cli](https://www.npmjs.com/package/@neon-rs/cli) utility to assemble the binary Node addon from the output of `cargo`.
## Exploring revolt.node
After building revolt.node, you can explore its exports at the Node console:
```sh
$ npm i
$ npm run build
$ node
> require('.').hello()
'hello node'
```
## Available Scripts
In the project directory, you can run:
#### `npm install`
Installs the project, including running `npm run build`.
#### `npm run build`
Builds the Node addon (`index.node`) from source, generating a release build with `cargo --release`.
Additional [`cargo build`](https://doc.rust-lang.org/cargo/commands/cargo-build.html) arguments may be passed to `npm run build` and similar commands. For example, to enable a [cargo feature](https://doc.rust-lang.org/cargo/reference/features.html):
```
npm run build -- --feature=beetle
```
#### `npm run debug`
Similar to `npm run build` but generates a debug build with `cargo`.
#### `npm run cross`
Similar to `npm run build` but uses [cross-rs](https://github.com/cross-rs/cross) to cross-compile for another platform. Use the [`CARGO_BUILD_TARGET`](https://doc.rust-lang.org/cargo/reference/config.html#buildtarget) environment variable to select the build target.
#### `npm test`
Runs the unit tests by calling `cargo test`. You can learn more about [adding tests to your Rust code](https://doc.rust-lang.org/book/ch11-01-writing-tests.html) from the [Rust book](https://doc.rust-lang.org/book/).
## Project Layout
The directory structure of this project is:
```
revolt.node/
├── Cargo.toml
├── README.md
├── src/
| └── lib.rs
├── index.node
├── package.json
└── target/
```
| Entry | Purpose |
|----------------|------------------------------------------------------------------------------------------------------------------------------------------|
| `Cargo.toml` | The Cargo [manifest file](https://doc.rust-lang.org/cargo/reference/manifest.html), which informs the `cargo` command. |
| `README.md` | This file. |
| `src/` | The directory tree containing the Rust source code for the project. |
| `lib.rs` | Entry point for the Rust source code. |
| `index.node` | The main module, a [Node addon](https://nodejs.org/api/addons.html) generated by the build and pointed to by `"main"` in `package.json`. |
| `package.json` | The npm [manifest file](https://docs.npmjs.com/cli/v7/configuring-npm/package-json), which informs the `npm` command. |
| `target/` | Binary artifacts generated by the Rust build. |
## Learn More
Learn more about:
- [Neon](https://neon-bindings.com).
- [Rust](https://www.rust-lang.org).
- [Node](https://nodejs.org).

84
crates/bindings/node/index.d.ts vendored Normal file
View File

@@ -0,0 +1,84 @@
import { Channel, User } from "revolt-api";
/**
* Opaque type for Revolt database
*/
export declare interface Database {}
/**
* Opaque type for Revolt database
*/
export declare interface OpaqueUser {}
/**
* Error type from Revolt backend
*/
export declare interface Err {
type: string;
location: string;
}
/**
* Initialises background tasks and logging, must be called before anything else!
* Can be called multiple times!
*/
export declare function init();
/**
* Gets a new handle to the Revolt database
* @returns {Database} Handle
*/
export declare function database(): Database;
/**
* Fetch user from database
* @param {string} userId User's ID
* @this {Database}
*/
export declare function database_fetch_user(userId: string): OpaqueUser;
/**
* Fetch user from database
* @param {string} username Username
* @param {string} discriminator Discriminator
* @this {Database}
*/
export declare function database_fetch_user_by_username(
username: string,
discriminator: string
): OpaqueUser;
/**
* Gets model data as JSON
* @this {OpaqueUser}
*/
export declare function model_data(): User;
/**
* Gets error if the model failed to fetch
* @this {OpaqueUser}
*/
export declare function model_error(): Err;
/**
* Open a direct message channel between two users
* @param {string} userA User A ID
* @param {string} userB User B ID
* @returns Existing or newly created channel
*/
export declare function proc_channels_create_dm(
userA: string,
userB: string
): Promise<Channel & { error: Err }>;
/**
* Suspend a user
* @param {string} user User
* @param {number} duration Duration (in days), set to 0 for indefinite
* @param {string} reason Pipe-separated list of reasons (e.g. reason1|reason2|reason3)
*/
export declare function proc_users_suspend(
user: OpaqueUser,
duration: number,
reason: string
): Promise<{ error: Err }>;

View File

@@ -0,0 +1,36 @@
{
"name": "revolt-nodejs-bindings",
"version": "0.7.15-rev0.0.3",
"description": "Node.js bindings for the Revolt software",
"main": "index.node",
"scripts": {
"test": "cargo test",
"cargo-build": "cargo build --message-format=json > cargo.log",
"cross-build": "cross build --message-format=json > cross.log",
"postcargo-build": "neon dist < cargo.log",
"postcross-build": "neon dist -m /target < cross.log",
"debug": "npm run cargo-build --",
"build": "npm run cargo-build -- --release",
"cross": "npm run cross-build -- --release"
},
"author": "Paul Makles",
"license": "AGPL-3.0",
"devDependencies": {
"@neon-rs/cli": "0.1.73"
},
"repository": {
"type": "git",
"url": "git+https://github.com/revoltchat/backend"
},
"keywords": [
"revolt",
"chat"
],
"bugs": {
"url": "https://github.com/revoltchat/backend/issues"
},
"homepage": "https://github.com/revoltchat/backend#readme",
"dependencies": {
"revolt-api": "^0.7.15"
}
}

117
crates/bindings/node/pnpm-lock.yaml generated Normal file
View File

@@ -0,0 +1,117 @@
lockfileVersion: '6.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
dependencies:
revolt-api:
specifier: ^0.7.15
version: 0.7.15
devDependencies:
'@neon-rs/cli':
specifier: 0.1.73
version: 0.1.73
packages:
/@cargo-messages/android-arm-eabi@0.1.72:
resolution: {integrity: sha512-gGZxIM1mj+Y5x+ULND6ZCNr7f70OJi9wDlycSK8hGONy9wrChN6JAIHryddC5cqcwlYAoQ6IDcDFElnhAYbybA==}
cpu: [arm]
os: [android]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/darwin-arm64@0.1.72:
resolution: {integrity: sha512-EAzN5MLaXPljZKZDO5qR+aBs44eSq2ZbEnS7AI/FziE3MzeXbrGOS3fLba5+7yWPFXJyZolXzePm8N1EBv8ovg==}
cpu: [arm64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/darwin-x64@0.1.72:
resolution: {integrity: sha512-RLo6j8s3nYbjdd1LDct4wamfChyRit7zokUuxtIYCu9XOlltkN5vnj1vwnrPvoqCMZ/7CbbuHFwSTn9A71de/w==}
cpu: [x64]
os: [darwin]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/linux-arm-gnueabihf@0.1.72:
resolution: {integrity: sha512-tHsRshuzfjrX6SDW3jg6al8vMNLTMgczGnVYl5RuBZf/yrAUuwe30KxA9ge6w6mW6Ox797DyBchzAc9OLgTgmQ==}
cpu: [arm]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/linux-x64-gnu@0.1.72:
resolution: {integrity: sha512-VGtL6CCnUbhsP4aYuBNT5kfrAL7o0qjrxw97a+ax13t+nJd26tVEEIKHMu5drvvS/Nm/hn7sLT8zMnnCv0pvHg==}
cpu: [x64]
os: [linux]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/win32-arm64-msvc@0.1.72:
resolution: {integrity: sha512-V93Cgz39K+yqa3MveNbhh29pYCp8izK5uEavjPoxlNxAbsMCWH+s0verGDdUcfGxjR1H2V7oZ4FszPqR2SqMRQ==}
cpu: [arm64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@cargo-messages/win32-x64-msvc@0.1.72:
resolution: {integrity: sha512-knz3uSrO0OSbq3U5VWfCY8FB4NsM43BOWLZ7x4sfaMOC1XWv+IyvDdkLe6DhJx8KUw46KIAimYs9YROrp6l46Q==}
cpu: [x64]
os: [win32]
requiresBuild: true
dev: true
optional: true
/@neon-rs/cli@0.1.73:
resolution: {integrity: sha512-1kv8S/feB6UQWQQwsnGfkSkEBOtlFDNExnioL81E2BwvUWgjQPaseHgpi2EpWVgsPUgur5eBm4QowmlpWkD4/w==}
hasBin: true
optionalDependencies:
'@cargo-messages/android-arm-eabi': 0.1.72
'@cargo-messages/darwin-arm64': 0.1.72
'@cargo-messages/darwin-x64': 0.1.72
'@cargo-messages/linux-arm-gnueabihf': 0.1.72
'@cargo-messages/linux-x64-gnu': 0.1.72
'@cargo-messages/win32-arm64-msvc': 0.1.72
'@cargo-messages/win32-x64-msvc': 0.1.72
dev: true
/axios@0.26.1:
resolution: {integrity: sha512-fPwcX4EvnSHuInCMItEhAGnaSEXRBjtzh9fOtsE6E1G6p7vl7edEeZe11QHf18+6+9gR5PbKV/sGKNaD8YaMeA==}
dependencies:
follow-redirects: 1.15.6
transitivePeerDependencies:
- debug
dev: false
/follow-redirects@1.15.6:
resolution: {integrity: sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==}
engines: {node: '>=4.0'}
peerDependencies:
debug: '*'
peerDependenciesMeta:
debug:
optional: true
dev: false
/lodash.defaultsdeep@4.6.1:
resolution: {integrity: sha512-3j8wdDzYuWO3lM3Reg03MuQR957t287Rpcxp1njpEa8oDrikb+FwGdW3n+FELh/A6qib6yPit0j/pv9G/yeAqA==}
dev: false
/revolt-api@0.7.15:
resolution: {integrity: sha512-rWRnjBFvoXt/RHRxHg9KuP+x1Jk61KdsOpKOczVJg5tIqGDmqp/6QZHw5rJTtG+wCH3Ah3YKwY+MM5NHfg1W3Q==}
dependencies:
axios: 0.26.1
lodash.defaultsdeep: 4.6.1
transitivePeerDependencies:
- debug
dev: false

View File

@@ -0,0 +1,221 @@
#[macro_use]
extern crate serde;
use std::sync::OnceLock;
use neon::prelude::*;
use revolt_database::{Database, DatabaseInfo};
fn js_init(mut cx: FunctionContext) -> JsResult<JsUndefined> {
// static INIT: OnceLock<()> = OnceLock::new();
// if INIT.get().is_none() {
// INIT.get_or_init(|| {
// async_std::task::block_on(async {
// revolt_config::configure!(api);
// match DatabaseInfo::Auto.connect().await {
// Ok(db) => {
// let authifier_db = db.clone().to_authifier().await.database;
// revolt_database::tasks::start_workers(db, authifier_db);
// Ok(())
// }
// Err(err) => Err(err),
// }
// })
// .or_else(|err| cx.throw_error(err))
// .unwrap();
// });
// }
Ok(cx.undefined())
}
struct DatabaseBinding(Database, Channel);
impl Finalize for DatabaseBinding {}
impl DatabaseBinding {
fn take(&self) -> (Database, Channel) {
(self.0.clone(), self.1.clone())
}
}
fn js_database(mut cx: FunctionContext) -> JsResult<JsBox<DatabaseBinding>> {
let db = async_std::task::block_on(DatabaseInfo::Auto.connect())
.or_else(|err| cx.throw_error(err))?;
let channel = cx.channel();
Ok(cx.boxed(DatabaseBinding(db, channel)))
}
// Implementations for models
#[derive(Clone)]
#[allow(clippy::large_enum_variant)]
enum Model {
User(revolt_database::User),
Error(revolt_result::Error),
}
impl Model {
fn give(&self) -> Model {
self.clone()
}
}
impl Finalize for Model {}
macro_rules! shim_boxed {
($cx: ident, $name: ident, $model: ident, $( $variable: ident $type: ident $id: expr )+, $cmd: ident, $( $arg: expr, )+) => {
fn $name(mut cx: FunctionContext) -> JsResult<JsPromise> {
$(
let $variable = cx.argument::<$type>($id)?.value(&mut cx);
)+
let (db, channel) = cx.this::<JsBox<DatabaseBinding>>()?.take();
let (deferred, promise) = cx.promise();
async_std::task::spawn(async move {
let result = db.$cmd($($arg,)+).await;
deferred.settle_with(&channel, move |mut cx| {
Ok(cx.boxed(match result {
Ok(value) => Model::$model(value),
Err(error) => Model::Error(error)
}))
})
});
Ok(promise)
}
$cx.export_function(stringify!($name), $name)?;
};
}
fn js_data(mut cx: FunctionContext) -> JsResult<JsValue> {
match cx.this::<JsBox<Model>>()?.give() {
Model::Error(_) => neon_serde4::to_value(&mut cx, &None::<()>),
Model::User(user) => neon_serde4::to_value(&mut cx, &user),
}
.or_else(|e| cx.throw_error(e.to_string()))
}
fn js_error(mut cx: FunctionContext) -> JsResult<JsValue> {
let value = match cx.this::<JsBox<Model>>()?.give() {
Model::Error(err) => Some(err),
_ => None,
};
neon_serde4::to_value(&mut cx, &value).or_else(|e| cx.throw_error(e.to_string()))
}
// Basic data implementation
#[derive(Serialize, Deserialize)]
struct ResultBinding<T> {
#[serde(flatten)]
value: Option<T>,
error: Option<revolt_result::Error>,
}
macro_rules! shim {
($cx: ident, $name: ident, $( $variable: ident $type: ident $id: expr )*, $( $model: ident $modelType: ident $modelId: expr )*, | $db: ident | $closure: expr, $( $arg: expr, )+) => {
fn $name(mut cx: FunctionContext) -> JsResult<JsPromise> {
$(
let $variable = cx.argument::<$type>($id)?.value(&mut cx);
)*
$(
let mut $model = if let Model::$modelType(value) = cx.argument::<JsBox<Model>>($modelId)?.give() {
value
} else {
unreachable!()
};
)*
let (db, channel) = cx.this::<JsBox<DatabaseBinding>>()?.take();
let (deferred, promise) = cx.promise();
async_std::task::spawn(async move {
#[allow(clippy::redundant_closure_call)]
let result = (|$db: $crate::Database| $closure)(db.clone()).await;
deferred.settle_with(&channel, move |mut cx| {
neon_serde4::to_value(
&mut cx,
&match result {
Ok(value) => ResultBinding {
value: Some(value),
error: None,
},
Err(error) => ResultBinding {
value: None,
error: Some(error),
},
},
)
.or_else(|e| cx.throw_error(e.to_string()))
})
});
Ok(promise)
}
$cx.export_function(stringify!($name), $name)?;
};
}
#[neon::main]
fn main(mut cx: ModuleContext) -> NeonResult<()> {
// initialise required background stuff
cx.export_function("init", js_init)?;
// database & model stuff
cx.export_function("database", js_database)?;
cx.export_function("model_data", js_data)?;
cx.export_function("model_error", js_error)?;
shim_boxed!(
cx,
database_fetch_user,
User,
user_id JsString 0,
fetch_user,
&user_id,
);
shim_boxed!(
cx,
database_fetch_user_by_username,
User,
username JsString 0
discriminator JsString 1,
fetch_user_by_username,
&username, &discriminator,
);
// procedure calls
shim!(
cx,
proc_channels_create_dm,
user_a JsString 0
user_b JsString 1,
,
|db| async move {
let user_a = db.fetch_user(&user_a).await?;
let user_b = db.fetch_user(&user_b).await?;
revolt_database::Channel::create_dm(&db, &user_a, &user_b).await
},
&userA, &userB,
);
shim!(
cx,
proc_users_suspend,
duration JsNumber 1
reason JsString 2,
user User 0,
|db| async move {
let duration = duration as usize;
user.suspend(&db, if duration == 0 { None } else { Some(duration) }, Some(reason.split('|').map(|x| x.to_owned()).collect())).await
},
&user,
);
Ok(())
}

View File

@@ -0,0 +1,53 @@
const Internal = require(".");
// playing around with class wrapper, not practical
class Model {
constructor(model) {
this.model = model;
}
data() {
return Internal.model_data.bind(this.model)();
}
error() {
return Internal.model_error.bind(this.model)();
}
}
class User extends Model {
constructor(db, user) {
super(user);
this.db = db;
}
}
class Database {
constructor() {
this.db = Internal.database();
}
async fetchUser(userId) {
return new User(
this,
await Internal.database_fetch_user.bind(this.db)(userId)
);
}
async fetchUserByUsername(username, discriminator) {
return new User(
this,
await Internal.database_fetch_user_by_username.bind(this.db)(
username,
discriminator
)
);
}
}
const db = new Database();
db.fetchUserByUsername("dos", "7624").then((user) => console.info(user.data()));
db.fetchUserByUsername("dos", "1111").then((user) => console.info(user.data()));
db.fetchUserByUsername("dos", "1111").then((user) =>
console.info(user.error())
);

6
crates/bindings/package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "bindings",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-bonfire" name = "revolt-bonfire"
version = "0.7.1" version = "0.8.1"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
edition = "2021" edition = "2021"
@@ -14,6 +14,8 @@ lru = "0.7.6"
ulid = "0.5.0" ulid = "0.5.0"
once_cell = "1.9.0" once_cell = "1.9.0"
redis-kiss = "0.1.4" redis-kiss = "0.1.4"
lru_time_cache = "0.11.11"
async-channel = "2.3.1"
# parsing # parsing
querystring = "1.1.0" querystring = "1.1.0"
@@ -34,11 +36,12 @@ async-std = { version = "1.8.0", features = [
] } ] }
# core # core
authifier = { version = "1.0.9" }
revolt-result = { path = "../core/result" } revolt-result = { path = "../core/result" }
revolt-models = { path = "../core/models" } revolt-models = { path = "../core/models" }
revolt-config = { path = "../core/config" } revolt-config = { path = "../core/config" }
revolt-database = { path = "../core/database" } revolt-database = { path = "../core/database" }
revolt-permissions = { version = "0.7.1", path = "../core/permissions" } revolt-permissions = { version = "0.8.1", path = "../core/permissions" }
revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] } revolt-presence = { path = "../core/presence", features = ["redis-is-patched"] }
revolt-voice = { path = "../core/voice" } revolt-voice = { path = "../core/voice" }

View File

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

View File

@@ -1,5 +1,6 @@
use async_tungstenite::tungstenite::{handshake, Message}; use async_tungstenite::tungstenite::{handshake, Message};
use futures::channel::oneshot::Sender; use futures::channel::oneshot::Sender;
use revolt_database::events::client::ReadyPayloadFields;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -83,6 +84,17 @@ impl ProtocolConfiguration {
pub fn get_protocol_format(&self) -> &ProtocolFormat { pub fn get_protocol_format(&self) -> &ProtocolFormat {
&self.format &self.format
} }
/// Get ready payload fields
pub fn get_ready_payload_fields(&self) -> Vec<ReadyPayloadFields> {
vec![
ReadyPayloadFields::Users,
ReadyPayloadFields::Servers,
ReadyPayloadFields::Channels,
ReadyPayloadFields::Members,
ReadyPayloadFields::Emoji,
]
}
} }
/// Object holding one side of a channel for receiving the parsed information /// Object holding one side of a channel for receiving the parsed information

View File

@@ -1,8 +1,9 @@
use std::collections::HashSet; use std::collections::HashSet;
use revolt_database::{ use revolt_database::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, Member, events::client::{EventV1, ReadyPayloadFields},
MemberCompositeKey, Presence, RelationshipStatus, util::permissions::DatabasePermissionQuery,
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
}; };
use revolt_models::v0; use revolt_models::v0;
use revolt_permissions::{calculate_channel_permissions, ChannelPermission}; use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
@@ -23,11 +24,11 @@ impl Cache {
let server = self.servers.get(server); let server = self.servers.get(server);
let mut query = let mut query =
DatabasePermissionQuery::new(db, self.users.get(&self.user_id).unwrap()) DatabasePermissionQuery::new(db, self.users.get(&self.user_id).unwrap())
.channel(&channel); .channel(channel);
// let mut perms = perms(self.users.get(&self.user_id).unwrap()).channel(channel); // let mut perms = perms(self.users.get(&self.user_id).unwrap()).channel(channel);
if let Some(member) = member { if let Some(member) = member {
query = query.member(&member); query = query.member(member);
} }
if let Some(server) = server { if let Some(server) = server {
@@ -92,8 +93,13 @@ impl Cache {
/// State Manager /// State Manager
impl State { impl State {
/// Generate a Ready packet for the current user /// Generate a Ready packet for the current user
pub async fn generate_ready_payload(&mut self, db: &Database) -> Result<EventV1> { pub async fn generate_ready_payload(
&mut self,
db: &Database,
fields: Vec<ReadyPayloadFields>,
) -> Result<EventV1> {
let user = self.clone_user(); let user = self.clone_user();
self.cache.is_bot = user.bot.is_some();
// Find all relationships to the user. // Find all relationships to the user.
let mut user_ids: HashSet<String> = user let mut user_ids: HashSet<String> = user
@@ -151,14 +157,36 @@ impl State {
.await?; .await?;
// Fetch customisations. // Fetch customisations.
let emojis = db let emojis = if fields.contains(&ReadyPayloadFields::Emoji) {
.fetch_emoji_by_parent_ids( Some(
&servers db.fetch_emoji_by_parent_ids(
.iter() &servers
.map(|x| x.id.to_string()) .iter()
.collect::<Vec<String>>(), .map(|x| x.id.to_string())
.collect::<Vec<String>>(),
)
.await?,
) )
.await?; } else {
None
};
// Fetch user settings
let user_settings = if let Some(ReadyPayloadFields::UserSettings(keys)) = fields
.iter()
.find(|e| matches!(e, ReadyPayloadFields::UserSettings(_)))
{
Some(db.fetch_user_settings(&user.id, &keys).await?)
} else {
None
};
// Fetch channel unreads
let channel_unreads = if fields.contains(&ReadyPayloadFields::ChannelUnreads) {
Some(db.fetch_unreads(&user.id).await?)
} else {
None
};
// Copy data into local state cache. // Copy data into local state cache.
self.cache.users = users.iter().cloned().map(|x| (x.id.clone(), x)).collect(); self.cache.users = users.iter().cloned().map(|x| (x.id.clone(), x)).collect();
@@ -181,40 +209,69 @@ impl State {
.collect(); .collect();
// Make sure we see our own user correctly. // Make sure we see our own user correctly.
users.push(user.into_self().await); users.push(user.into_self(true).await);
// Set subscription state internally. // Set subscription state internally.
self.reset_state(); self.reset_state().await;
self.insert_subscription(self.private_topic.clone()); self.insert_subscription(self.private_topic.clone()).await;
for user in &users { for user in &users {
self.insert_subscription(user.id.clone()); self.insert_subscription(user.id.clone()).await;
} }
for server in &servers { for server in &servers {
self.insert_subscription(server.id.clone()); self.insert_subscription(server.id.clone()).await;
}
for channel in &channels { if self.cache.is_bot {
self.insert_subscription(channel.id().to_string()); self.insert_subscription(format!("{}u", server.id)).await;
}
// fetch voice states for all the channels we can see
let mut voice_states = Vec::new();
for channel in &channels {
if let Ok(Some(voice_state)) = self.fetch_voice_state(channel).await {
voice_states.push(voice_state)
} }
} }
for channel in &channels {
self.insert_subscription(channel.id().to_string()).await;
}
let voice_states = if fields.contains(&ReadyPayloadFields::VoiceStates) {
// fetch voice states for all the channels we can see
let mut voice_states = Vec::new();
for channel in &channels {
if let Ok(Some(voice_state)) = self.fetch_voice_state(channel).await {
voice_states.push(voice_state)
}
}
Some(voice_states)
} else {
None
};
Ok(EventV1::Ready { Ok(EventV1::Ready {
users, users: if fields.contains(&ReadyPayloadFields::Users) {
servers: servers.into_iter().map(Into::into).collect(), Some(users)
channels: channels.into_iter().map(Into::into).collect(), } else {
members: members.into_iter().map(Into::into).collect(), None
emojis: emojis.into_iter().map(Into::into).collect(), },
servers: if fields.contains(&ReadyPayloadFields::Servers) {
Some(servers.into_iter().map(Into::into).collect())
} else {
None
},
channels: if fields.contains(&ReadyPayloadFields::Channels) {
Some(channels.into_iter().map(Into::into).collect())
} else {
None
},
members: if fields.contains(&ReadyPayloadFields::Members) {
Some(members.into_iter().map(Into::into).collect())
} else {
None
},
emojis: emojis.map(|vec| vec.into_iter().map(Into::into).collect()),
voice_states, voice_states,
user_settings,
channel_unreads: channel_unreads.map(|vec| vec.into_iter().map(Into::into).collect()),
}) })
} }
@@ -248,11 +305,11 @@ impl State {
let mut bulk_events = vec![]; let mut bulk_events = vec![];
for id in added_channels { for id in added_channels {
self.insert_subscription(id); self.insert_subscription(id).await;
} }
for id in removed_channels { for id in removed_channels {
self.remove_subscription(&id); self.remove_subscription(&id).await;
self.cache.channels.remove(&id); self.cache.channels.remove(&id);
bulk_events.push(EventV1::ChannelDelete { id }); bulk_events.push(EventV1::ChannelDelete { id });
@@ -275,7 +332,7 @@ impl State {
.channels .channels
.insert(channel.id().to_string(), channel.clone()); .insert(channel.id().to_string(), channel.clone());
self.insert_subscription(channel.id().to_string()); self.insert_subscription(channel.id().to_string()).await;
bulk_events.push(EventV1::ChannelCreate(channel.into())); bulk_events.push(EventV1::ChannelCreate(channel.into()));
} }
} }
@@ -348,7 +405,7 @@ impl State {
match event { match event {
EventV1::ChannelCreate(channel) => { EventV1::ChannelCreate(channel) => {
let id = channel.id().to_string(); let id = channel.id().to_string();
self.insert_subscription(id.clone()); self.insert_subscription(id.clone()).await;
self.cache.channels.insert(id, channel.clone().into()); self.cache.channels.insert(id, channel.clone().into());
} }
EventV1::ChannelUpdate { EventV1::ChannelUpdate {
@@ -388,17 +445,17 @@ impl State {
} }
} }
EventV1::ChannelDelete { id } => { EventV1::ChannelDelete { id } => {
self.remove_subscription(id); self.remove_subscription(id).await;
self.cache.channels.remove(id); self.cache.channels.remove(id);
} }
EventV1::ChannelGroupJoin { user, .. } => { EventV1::ChannelGroupJoin { user, .. } => {
self.insert_subscription(user.clone()); self.insert_subscription(user.clone()).await;
} }
EventV1::ChannelGroupLeave { id, user, .. } => { EventV1::ChannelGroupLeave { id, user, .. } => {
if user == &self.cache.user_id { if user == &self.cache.user_id {
self.remove_subscription(id); self.remove_subscription(id).await;
} else if !self.cache.can_subscribe_to_user(user) { } else if !self.cache.can_subscribe_to_user(user) {
self.remove_subscription(user); self.remove_subscription(user).await;
} }
} }
@@ -408,7 +465,12 @@ impl State {
channels, channels,
emojis: _, emojis: _,
} => { } => {
self.insert_subscription(id.clone()); self.insert_subscription(id.clone()).await;
if self.cache.is_bot {
self.insert_subscription(format!("{}u", id)).await;
}
self.cache.servers.insert(id.clone(), server.clone().into()); self.cache.servers.insert(id.clone(), server.clone().into());
let member = Member { let member = Member {
id: MemberCompositeKey { id: MemberCompositeKey {
@@ -445,13 +507,13 @@ impl State {
EventV1::ServerMemberJoin { .. } => { EventV1::ServerMemberJoin { .. } => {
// We will always receive ServerCreate when joining a new server. // We will always receive ServerCreate when joining a new server.
} }
EventV1::ServerMemberLeave { id, user } => { EventV1::ServerMemberLeave { id, user, .. } => {
if user == &self.cache.user_id { if user == &self.cache.user_id {
self.remove_subscription(id); self.remove_subscription(id).await;
if let Some(server) = self.cache.servers.remove(id) { if let Some(server) = self.cache.servers.remove(id) {
for channel in &server.channels { for channel in &server.channels {
self.remove_subscription(channel); self.remove_subscription(channel).await;
self.cache.channels.remove(channel); self.cache.channels.remove(channel);
} }
} }
@@ -459,11 +521,11 @@ impl State {
} }
} }
EventV1::ServerDelete { id } => { EventV1::ServerDelete { id } => {
self.remove_subscription(id); self.remove_subscription(id).await;
if let Some(server) = self.cache.servers.remove(id) { if let Some(server) = self.cache.servers.remove(id) {
for channel in &server.channels { for channel in &server.channels {
self.remove_subscription(channel); self.remove_subscription(channel).await;
self.cache.channels.remove(channel); self.cache.channels.remove(channel);
} }
} }
@@ -536,9 +598,23 @@ impl State {
self.cache.users.insert(id.clone(), user.clone().into()); self.cache.users.insert(id.clone(), user.clone().into());
if self.cache.can_subscribe_to_user(id) { if self.cache.can_subscribe_to_user(id) {
self.insert_subscription(id.clone()); self.insert_subscription(id.clone()).await;
} else { } else {
self.remove_subscription(id); self.remove_subscription(id).await;
}
}
EventV1::Message(message) => {
// Since Message events are fanned out to many clients,
// we must reconstruct the relationship value at this end.
if let Some(user) = &mut message.user {
user.relationship = self
.cache
.users
.get(&self.cache.user_id)
.expect("missing self?")
.relationship_with(&message.author)
.into();
} }
} }
@@ -552,11 +628,11 @@ impl State {
// Sub / unsub accordingly. // Sub / unsub accordingly.
if let Some(id) = queue_add { if let Some(id) = queue_add {
self.insert_subscription(id); self.insert_subscription(id).await;
} }
if let Some(id) = queue_remove { if let Some(id) = queue_remove {
self.remove_subscription(&id); self.remove_subscription(&id).await;
} }
true true
@@ -582,7 +658,7 @@ impl State {
} }
Ok(Some(v0::ChannelVoiceState { Ok(Some(v0::ChannelVoiceState {
id: channel.id(), id: channel.id().to_string(),
participants, participants,
})) }))
} else { } else {

View File

@@ -1,6 +1,12 @@
use std::collections::{HashMap, HashSet}; use std::{
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use async_std::sync::{Mutex, RwLock};
use lru::LruCache; use lru::LruCache;
use lru_time_cache::{LruCache as LruTimeCache, TimedEntry};
use revolt_database::{Channel, Member, Server, User}; use revolt_database::{Channel, Member, Server, User};
/// Enumeration representing some change in subscriptions /// Enumeration representing some change in subscriptions
@@ -30,6 +36,7 @@ pub enum SubscriptionStateChange {
#[derive(Debug)] #[derive(Debug)]
pub struct Cache { pub struct Cache {
pub user_id: String, pub user_id: String,
pub is_bot: bool,
pub users: HashMap<String, User>, pub users: HashMap<String, User>,
pub channels: HashMap<String, Channel>, pub channels: HashMap<String, Channel>,
@@ -43,6 +50,7 @@ impl Default for Cache {
fn default() -> Self { fn default() -> Self {
Cache { Cache {
user_id: Default::default(), user_id: Default::default(),
is_bot: false,
users: Default::default(), users: Default::default(),
channels: Default::default(), channels: Default::default(),
@@ -58,14 +66,17 @@ impl Default for Cache {
pub struct State { pub struct State {
pub cache: Cache, pub cache: Cache,
pub session_id: String,
pub private_topic: String, pub private_topic: String,
subscribed: HashSet<String>, pub state: SubscriptionStateChange,
state: SubscriptionStateChange,
pub subscribed: Arc<RwLock<HashSet<String>>>,
pub active_servers: Arc<Mutex<LruTimeCache<String, ()>>>,
} }
impl State { impl State {
/// Create state from User /// Create state from User
pub fn from(user: User) -> State { pub fn from(user: User, session_id: String) -> State {
let mut subscribed = HashSet::new(); let mut subscribed = HashSet::new();
let private_topic = format!("{}!", user.id); let private_topic = format!("{}!", user.id);
subscribed.insert(private_topic.clone()); subscribed.insert(private_topic.clone());
@@ -80,22 +91,61 @@ impl State {
State { State {
cache, cache,
subscribed, subscribed: Arc::new(RwLock::new(subscribed)),
active_servers: Arc::new(Mutex::new(LruTimeCache::with_expiry_duration_and_capacity(
Duration::from_secs(900),
5,
))),
session_id,
private_topic, private_topic,
state: SubscriptionStateChange::Reset, state: SubscriptionStateChange::Reset,
} }
} }
/// Apply currently queued state /// Apply currently queued state
pub fn apply_state(&mut self) -> SubscriptionStateChange { pub async fn apply_state(&mut self) -> SubscriptionStateChange {
// Check if we need to change subscriptions to member event topics
if !self.cache.is_bot {
enum Server {
Subscribe(String),
Unsubscribe(String),
}
let active_server_changes: Vec<Server> = {
let mut active_servers = self.active_servers.lock().await;
active_servers
.notify_iter()
.map(|e| match e {
TimedEntry::Valid(k, _) => Server::Subscribe(format!("{}u", k)),
TimedEntry::Expired(k, _) => Server::Unsubscribe(format!("{}u", k)),
})
.collect()
// It is bad practice to open more than one Mutex at once and could
// lead to a deadlock, so instead we choose to collect the changes.
};
for entry in active_server_changes {
match entry {
Server::Subscribe(k) => {
self.insert_subscription(k).await;
}
Server::Unsubscribe(k) => {
self.remove_subscription(&k).await;
}
}
}
}
// Flush changes to subscriptions
let state = std::mem::replace(&mut self.state, SubscriptionStateChange::None); let state = std::mem::replace(&mut self.state, SubscriptionStateChange::None);
let mut subscribed = self.subscribed.write().await;
if let SubscriptionStateChange::Change { add, remove } = &state { if let SubscriptionStateChange::Change { add, remove } = &state {
for id in add { for id in add {
self.subscribed.insert(id.clone()); subscribed.insert(id.clone());
} }
for id in remove { for id in remove {
self.subscribed.remove(id); subscribed.remove(id);
} }
} }
@@ -107,20 +157,16 @@ impl State {
self.cache.users.get(&self.cache.user_id).unwrap().clone() self.cache.users.get(&self.cache.user_id).unwrap().clone()
} }
/// Iterate through all subscriptions
pub fn iter_subscriptions(&self) -> std::collections::hash_set::Iter<'_, std::string::String> {
self.subscribed.iter()
}
/// Reset the current state /// Reset the current state
pub fn reset_state(&mut self) { pub async fn reset_state(&mut self) {
self.state = SubscriptionStateChange::Reset; self.state = SubscriptionStateChange::Reset;
self.subscribed.clear(); self.subscribed.write().await.clear();
} }
/// Add a new subscription /// Add a new subscription
pub fn insert_subscription(&mut self, subscription: String) { pub async fn insert_subscription(&mut self, subscription: String) {
if self.subscribed.contains(&subscription) { let mut subscribed = self.subscribed.write().await;
if subscribed.contains(&subscription) {
return; return;
} }
@@ -137,12 +183,13 @@ impl State {
SubscriptionStateChange::Reset => {} SubscriptionStateChange::Reset => {}
} }
self.subscribed.insert(subscription); subscribed.insert(subscription);
} }
/// Remove existing subscription /// Remove existing subscription
pub fn remove_subscription(&mut self, subscription: &str) { pub async fn remove_subscription(&mut self, subscription: &str) {
if !self.subscribed.contains(&subscription.to_string()) { let mut subscribed = self.subscribed.write().await;
if !subscribed.contains(&subscription.to_string()) {
return; return;
} }
@@ -159,6 +206,6 @@ impl State {
SubscriptionStateChange::Reset => panic!("Should not remove during a reset!"), SubscriptionStateChange::Reset => panic!("Should not remove during a reset!"),
} }
self.subscribed.remove(subscription); subscribed.remove(subscription);
} }
} }

View File

@@ -30,13 +30,16 @@ async fn main() {
database::connect().await; database::connect().await;
// Clean up the current region information. // Clean up the current region information.
clear_region(None).await; let no_clear_region = env::var("NO_CLEAR_PRESENCE").unwrap_or_else(|_| "0".into()) == "1";
if !no_clear_region {
clear_region(None).await;
}
VOICE_CLIENT.set(Arc::new(VoiceClient::from_revolt_config().await)).unwrap(); VOICE_CLIENT.set(Arc::new(VoiceClient::from_revolt_config().await)).unwrap();
// Setup a TCP listener to accept WebSocket connections on. // Setup a TCP listener to accept WebSocket connections on.
// By default, we bind to port 9000 on all interfaces. // By default, we bind to port 14703 on all interfaces.
let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:9000".into()); let bind = env::var("HOST").unwrap_or_else(|_| "0.0.0.0:14703".into());
info!("Listening on host {bind}"); info!("Listening on host {bind}");
let try_socket = TcpListener::bind(bind).await; let try_socket = TcpListener::bind(bind).await;
let listener = try_socket.expect("Failed to bind"); let listener = try_socket.expect("Failed to bind");

View File

@@ -1,24 +1,33 @@
use std::net::SocketAddr; use std::{collections::HashSet, net::SocketAddr, sync::Arc};
use async_tungstenite::WebSocketStream; use async_tungstenite::WebSocketStream;
use authifier::AuthifierEvent;
use fred::{ use fred::{
error::{RedisError, RedisErrorKind},
interfaces::{ClientLike, EventInterface, PubsubInterface}, interfaces::{ClientLike, EventInterface, PubsubInterface},
types::RedisConfig, types::RedisConfig,
}; };
use futures::{ use futures::{
channel::oneshot, channel::oneshot,
pin_mut, select, join, pin_mut, select,
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::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_config::report_internal_error;
use revolt_database::{ use revolt_database::{
events::{client::EventV1, server::ClientMessage}, events::{client::EventV1, server::ClientMessage},
Database, User, UserHint, Database, User, UserHint,
}; };
use revolt_presence::{create_session, delete_session}; use revolt_presence::{create_session, delete_session};
use async_std::{net::TcpStream, sync::Mutex}; use async_std::{
net::TcpStream,
sync::{Mutex, RwLock},
task::spawn,
};
use revolt_result::create_error;
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};
@@ -42,10 +51,12 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
else { else {
return; return;
}; };
// Verify we've received a valid config, otherwise we should just drop the connection. // Verify we've received a valid config, otherwise we should just drop the connection.
let Ok(mut config) = receiver.await else { let Ok(mut config) = receiver.await else {
return; return;
}; };
info!( info!(
"User {addr:?} provided protocol configuration (version = {}, format = {:?})", "User {addr:?} provided protocol configuration (version = {}, format = {:?})",
config.get_protocol_version(), config.get_protocol_version(),
@@ -57,10 +68,8 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// If the user has not provided authentication, request information. // If the user has not provided authentication, request information.
if config.get_session_token().is_none() { if config.get_session_token().is_none() {
while let Ok(message) = read.try_next().await { while let Ok(Some(message)) = read.try_next().await {
if let Ok(ClientMessage::Authenticate { token }) = if let Ok(ClientMessage::Authenticate { token }) = config.decode(&message) {
config.decode(message.as_ref().unwrap())
{
config.set_session_token(token); config.set_session_token(token);
break; break;
} }
@@ -69,35 +78,48 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// Try to authenticate the user. // Try to authenticate the user.
let Some(token) = config.get_session_token().as_ref() else { let Some(token) = config.get_session_token().as_ref() else {
write
.send(config.encode(&EventV1::Error {
data: create_error!(InvalidSession),
}))
.await
.ok();
return; return;
}; };
let user = match User::from_token(db, token, UserHint::Any).await {
let (user, session_id) = match User::from_token(db, token, UserHint::Any).await {
Ok(user) => user, Ok(user) => user,
Err(err) => { Err(err) => {
write.send(config.encode(&err)).await.ok(); write
.send(config.encode(&EventV1::Error { data: err }))
.await
.ok();
return; return;
} }
}; };
info!("User {addr:?} authenticated as @{}", user.username); info!("User {addr:?} authenticated as @{}", user.username);
// Create local state. // Create local state.
let mut state = State::from(user); let mut state = State::from(user, session_id);
let user_id = state.cache.user_id.clone(); let user_id = state.cache.user_id.clone();
// Notify socket we have authenticated. // Notify socket we have authenticated.
if write if report_internal_error!(write.send(config.encode(&EventV1::Authenticated)).await).is_err() {
.send(config.encode(&EventV1::Authenticated))
.await
.is_err()
{
return; return;
} }
// Download required data to local cache and send Ready payload. // Download required data to local cache and send Ready payload.
let Ok(ready_payload) = state.generate_ready_payload(db).await else { let ready_payload = match report_internal_error!(
return; state
.generate_ready_payload(db, config.get_ready_payload_fields())
.await
) {
Ok(ready_payload) => ready_payload,
Err(_) => return,
}; };
if write.send(config.encode(&ready_payload)).await.is_err() {
if report_internal_error!(write.send(config.encode(&ready_payload)).await).is_err() {
return; return;
} }
@@ -110,20 +132,44 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
} }
{ {
// Setup channels and mutexes
let write = Mutex::new(write); let write = Mutex::new(write);
let subscribed = state.subscribed.clone();
let active_servers = state.active_servers.clone();
let (topic_signal_s, topic_signal_r) = async_channel::unbounded();
// TODO: this needs to be rewritten
// Create channels through which the tasks can signal to each other they need to clean up
let (kill_signal_1_s, kill_signal_1_r) = async_channel::bounded(1);
let (kill_signal_2_s, kill_signal_2_r) = async_channel::bounded(1);
// Create a PubSub connection to poll on. // Create a PubSub connection to poll on.
let listener = listener(db, &mut state, addr, &config, &write).fuse(); let listener = listener_with_kill_signal(
// Read from WebSocket stream. db,
let worker = worker(addr, user_id.clone(), &config, read, &write).fuse(); &mut state,
addr,
// Pin both tasks. &config,
pin_mut!(listener, worker); topic_signal_r,
kill_signal_1_r,
// Wait for either disconnect or for listener to die. &write,
select!( kill_signal_2_s,
() = listener => {},
() = worker => {}
); );
// Read from WebSocket stream.
let worker = worker_with_kill_signal(
addr,
subscribed,
active_servers,
user_id.clone(),
&config,
topic_signal_s,
kill_signal_2_r,
read,
&write,
kill_signal_1_s,
);
join!(listener, worker);
} }
// Clean up presence session. // Clean up presence session.
let last_session = delete_session(&user_id, session_id).await; let last_session = delete_session(&user_id, session_id).await;
@@ -134,29 +180,79 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
} }
} }
#[allow(clippy::too_many_arguments)]
async fn listener_with_kill_signal(
db: &'static Database,
state: &mut State,
addr: SocketAddr,
config: &ProtocolConfiguration,
topic_signal_r: async_channel::Receiver<()>,
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>,
kill_signal_s: async_channel::Sender<()>,
) {
listener(
db,
state,
addr,
config,
topic_signal_r,
kill_signal_r,
write,
)
.await;
kill_signal_s.send(()).await.ok();
}
async fn listener( async fn listener(
db: &'static Database, db: &'static Database,
state: &mut State, state: &mut State,
addr: SocketAddr, addr: SocketAddr,
config: &ProtocolConfiguration, config: &ProtocolConfiguration,
topic_signal_r: async_channel::Receiver<()>,
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>, write: &Mutex<WsWriter>,
) { ) {
let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap(); let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap();
let Ok(subscriber) = fred::types::Builder::from_config(redis_config).build_subscriber_client() let subscriber = match report_internal_error!(
else { fred::types::Builder::from_config(redis_config).build_subscriber_client()
return; ) {
Ok(subscriber) => subscriber,
Err(_) => return,
}; };
if subscriber.init().await.is_err() {
if report_internal_error!(subscriber.init().await).is_err() {
return; return;
}; }
// Handle Redis connection dropping
let (clean_up_s, clean_up_r) = async_channel::bounded(1);
let clean_up_s = Arc::new(Mutex::new(clean_up_s));
subscriber.on_error(move |err| {
if let RedisErrorKind::Canceled = err.kind() {
let clean_up_s = clean_up_s.clone();
spawn(async move {
clean_up_s.lock().await.send(()).await.ok();
});
}
Ok(())
});
let mut message_rx = subscriber.message_rx(); let mut message_rx = subscriber.message_rx();
loop { 'out: loop {
// Check for state changes for subscriptions. // Check for state changes for subscriptions.
match state.apply_state() { match state.apply_state().await {
SubscriptionStateChange::Reset => { SubscriptionStateChange::Reset => {
subscriber.unsubscribe_all().await.unwrap(); if report_internal_error!(subscriber.unsubscribe_all().await).is_err() {
for id in state.iter_subscriptions() { break 'out;
subscriber.subscribe(id).await.unwrap(); }
let subscribed = state.subscribed.read().await;
for id in subscribed.iter() {
if report_internal_error!(subscriber.subscribe(id).await).is_err() {
break 'out;
}
} }
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
@@ -167,112 +263,242 @@ async fn listener(
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
info!("{addr:?} unsubscribing from {id}"); info!("{addr:?} unsubscribing from {id}");
subscriber.unsubscribe(id).await.unwrap(); if report_internal_error!(subscriber.unsubscribe(id).await).is_err() {
break 'out;
}
} }
for id in add { for id in add {
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
info!("{addr:?} subscribing to {id}"); info!("{addr:?} subscribing to {id}");
subscriber.subscribe(id).await.unwrap(); if report_internal_error!(subscriber.subscribe(id).await).is_err() {
break 'out;
}
} }
} }
SubscriptionStateChange::None => {} SubscriptionStateChange::None => {}
} }
// Handle incoming events. let t1 = message_rx.recv().fuse();
let Ok(message) = message_rx.recv().await.map_err(|e| { let t2 = topic_signal_r.recv().fuse();
warn!("Error while consuming pub/sub messages: {e:?}"); let t3 = kill_signal_r.recv().fuse();
sentry::capture_error(&e); let t4 = clean_up_r.recv().fuse();
}) else {
return;
};
let event = match *REDIS_PAYLOAD_TYPE {
PayloadType::Json => message
.value
.as_str()
.and_then(|s| serde_json::from_str::<EventV1>(s.as_ref()).ok()),
PayloadType::Msgpack => message
.value
.as_bytes()
.and_then(|b| rmp_serde::from_slice::<EventV1>(b).ok()),
PayloadType::Bincode => message
.value
.as_bytes()
.and_then(|b| bincode::deserialize::<EventV1>(b).ok()),
};
let Some(mut event) = event else {
warn!("Failed to deserialise an event for {}!", message.channel);
return;
};
let should_send = state.handle_incoming_event_v1(db, &mut event).await;
if !should_send {
continue;
}
let result = write.lock().await.send(config.encode(&event)).await; pin_mut!(t1, t2, t3, t4);
if let Err(e) = result {
use async_tungstenite::tungstenite::Error; select! {
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) { _ = t4 => {
warn!("Error while sending an event to {addr:?}: {e:?}"); break 'out;
},
_ = t3 => {
break 'out;
},
_ = t2 => {},
message = t1 => {
// Handle incoming events.
let message = match report_internal_error!(message) {
Ok(message) => message,
Err(_) => break 'out
};
let event = match *REDIS_PAYLOAD_TYPE {
PayloadType::Json => message
.value
.as_str()
.and_then(|s| report_internal_error!(serde_json::from_str::<EventV1>(s.as_ref())).ok()),
PayloadType::Msgpack => message
.value
.as_bytes()
.and_then(|b| report_internal_error!(rmp_serde::from_slice::<EventV1>(b)).ok()),
PayloadType::Bincode => message
.value
.as_bytes()
.and_then(|b| report_internal_error!(bincode::deserialize::<EventV1>(b)).ok()),
};
let Some(mut event) = event else {
let err = format!(
"Failed to deserialise event for {}: `{:?}`",
message.channel,
message
.value
);
error!("{}", err);
sentry::capture_message(&err, Level::Error);
break 'out;
};
if let EventV1::Auth(auth) = &event {
if let AuthifierEvent::DeleteSession { session_id, .. } = auth {
if &state.session_id == session_id {
event = EventV1::Logout;
}
} else if let AuthifierEvent::DeleteAllSessions {
exclude_session_id, ..
} = auth
{
if let Some(excluded) = exclude_session_id {
if &state.session_id != excluded {
event = EventV1::Logout;
}
} else {
event = EventV1::Logout;
}
}
} else {
let should_send = state.handle_incoming_event_v1(db, &mut event).await;
if !should_send {
continue;
}
}
let result = write.lock().await.send(config.encode(&event)).await;
if let Err(e) = result {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
let err = format!("Error while sending an event to {addr:?}: {e:?}");
warn!("{}", err);
sentry::capture_message(&err, Level::Warning);
}
break 'out;
}
if let EventV1::Logout = event {
info!("User {addr:?} received log out event!");
break 'out;
}
} }
return;
} }
} }
report_internal_error!(subscriber.quit().await).ok();
} }
async fn worker( #[allow(clippy::too_many_arguments)]
async fn worker_with_kill_signal(
addr: SocketAddr, addr: SocketAddr,
subscribed: Arc<RwLock<HashSet<String>>>,
active_servers: Arc<Mutex<lru_time_cache::LruCache<String, ()>>>,
user_id: String, user_id: String,
config: &ProtocolConfiguration, config: &ProtocolConfiguration,
topic_signal_s: async_channel::Sender<()>,
kill_signal_r: async_channel::Receiver<()>,
read: WsReader,
write: &Mutex<WsWriter>,
kill_signal_s: async_channel::Sender<()>,
) {
worker(
addr,
subscribed,
active_servers,
user_id,
config,
topic_signal_s,
kill_signal_r,
read,
write,
)
.await;
kill_signal_s.send(()).await.ok();
}
#[allow(clippy::too_many_arguments)]
async fn worker(
addr: SocketAddr,
subscribed: Arc<RwLock<HashSet<String>>>,
active_servers: Arc<Mutex<lru_time_cache::LruCache<String, ()>>>,
user_id: String,
config: &ProtocolConfiguration,
topic_signal_s: async_channel::Sender<()>,
kill_signal_r: async_channel::Receiver<()>,
mut read: WsReader, mut read: WsReader,
write: &Mutex<WsWriter>, write: &Mutex<WsWriter>,
) { ) {
loop { loop {
let result = read.try_next().await; let t1 = read.try_next().fuse();
let msg = match result { let t2 = kill_signal_r.recv().fuse();
Ok(Some(msg)) => msg,
Ok(None) => return,
Err(e) => {
use async_tungstenite::tungstenite::Error;
if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
warn!("Error while reading an event from {addr:?}: {e:?}");
}
return;
}
};
let Ok(payload) = config.decode(&msg) else { pin_mut!(t1, t2);
continue;
}; select! {
match payload { _ = t2 => {
ClientMessage::BeginTyping { channel } => { return;
EventV1::ChannelStartTyping { },
id: channel.clone(), result = t1 => {
user: user_id.clone(), let msg = match result {
} Ok(Some(msg)) => msg,
.p(channel.clone()) Ok(None) => {
.await; warn!("Received a None message!");
} sentry::capture_message("Received a None message!", Level::Warning);
ClientMessage::EndTyping { channel } => { return;
EventV1::ChannelStopTyping { }
id: channel.clone(), Err(e) => {
user: user_id.clone(), use async_tungstenite::tungstenite::Error;
} if !matches!(e, Error::AlreadyClosed | Error::ConnectionClosed) {
.p(channel.clone()) let err = format!("Error while reading an event from {addr:?}: {e:?}");
.await; warn!("{}", err);
} sentry::capture_message(&err, Level::Warning);
ClientMessage::Ping { data, responded } => { }
if responded.is_none() {
write return;
.lock() }
.await };
.send(config.encode(&EventV1::Pong { data }))
.await let Ok(payload) = config.decode(&msg) else {
.ok(); continue;
};
match payload {
ClientMessage::BeginTyping { channel } => {
if !subscribed.read().await.contains(&channel) {
continue;
}
EventV1::ChannelStartTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::EndTyping { channel } => {
if !subscribed.read().await.contains(&channel) {
continue;
}
EventV1::ChannelStopTyping {
id: channel.clone(),
user: user_id.clone(),
}
.p(channel.clone())
.await;
}
ClientMessage::Subscribe { server_id } => {
let mut servers = active_servers.lock().await;
let has_item = servers.contains_key(&server_id);
servers.insert(server_id, ());
if !has_item {
// Poke the listener to adjust subscriptions
topic_signal_s.send(()).await.ok();
}
}
ClientMessage::Ping { data, responded } => {
if responded.is_none() {
write
.lock()
.await
.send(config.encode(&EventV1::Pong { data }))
.await
.ok();
}
}
_ => {}
} }
} }
_ => {}
} }
} }
} }

View File

@@ -1,20 +1,20 @@
[package] [package]
name = "revolt-config" name = "revolt-config"
version = "0.7.1" version = "0.8.1"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Configuration" description = "Revolt Backend: Configuration"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features] [features]
report-macros = ["revolt-result"]
test = ["async-std"] test = ["async-std"]
default = ["test"] default = ["test"]
[dependencies] [dependencies]
# Utility # Utility
dotenv = "0.15.0"
config = "0.13.3" config = "0.13.3"
cached = "0.44.0" cached = "0.44.0"
once_cell = "1.18.0" once_cell = "1.18.0"
@@ -32,3 +32,6 @@ pretty_env_logger = "0.4.0"
# Sentry # Sentry
sentry = "0.31.5" sentry = "0.31.5"
# Core
revolt-result = { version = "0.8.1", path = "../result", optional = true }

View File

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

View File

@@ -1,3 +1,9 @@
[database] [database]
mongodb = "mongodb://localhost" mongodb = "mongodb://localhost"
redis = "redis://localhost/" redis = "redis://localhost/"
[rabbit]
host = "127.0.0.1"
port = 5672
username = "rabbituser"
password = "rabbitpass"

View File

@@ -1,72 +1,271 @@
[database] [database]
# MongoDB connection URL
# Defaults to the container name specified in self-hosted
mongodb = "mongodb://database" mongodb = "mongodb://database"
# Redis connection URL
# Defaults to the container name specified in self-hosted
redis = "redis://redis/" redis = "redis://redis/"
[hosts] [hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/revoltchat/self-hosted/blob/master/Caddyfile
#
# Remember to change these to https/wss where appropriate in production!
app = "http://local.revolt.chat" app = "http://local.revolt.chat"
api = "http://local.revolt.chat/api" api = "http://local.revolt.chat/api"
events = "ws://local.revolt.chat/ws" events = "ws://local.revolt.chat/ws"
autumn = "http://local.revolt.chat/autumn" autumn = "http://local.revolt.chat/autumn"
january = "http://local.revolt.chat/january" january = "http://local.revolt.chat/january"
livekit = "ws://local.revolt.chat/livekit"
voso_legacy = "" voso_legacy = ""
voso_legacy_ws = "" voso_legacy_ws = ""
[rabbit]
host = "rabbit"
port = 5672
username = "rabbituser"
password = "rabbitpass"
[api] [api]
[api.registration] [api.registration]
# Whether an invite should be required for registration
# See https://github.com/revoltchat/self-hosted#making-your-instance-invite-only
invite_only = false invite_only = false
[api.smtp] [api.smtp]
# Email server configuration for verification
# Defaults to no email verification (host field is empty)
host = "" host = ""
username = "" username = ""
password = "" password = ""
from_address = "" from_address = "noreply@example.com"
# reply_to = "noreply@example.com"
# port = 587
# use_tls = true
[api.vapid]
private_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUJSUWpyTWxLRnBiVWhsUHpUbERvcEliYk1yeVNrNXpKYzVYVzIxSjJDS3hvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFWnkrQkg2TGJQZ2hEa3pEempXOG0rUXVPM3pCajRXT1phdkR6ZU00c0pqbmFwd1psTFE0WAp1ZDh2TzVodU94QWhMQlU3WWRldVovWHlBdFpWZmNyQi9BPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo"
public_key = "BGcvgR-i2z4IQ5Mw841vJvkLjt8wY-FjmWrw83jOLCY52qcGZS0OF7nfLzuYbjsQISwVO2HXrmf18gLWVX3Kwfw="
[api.fcm]
api_key = ""
[api.security] [api.security]
# Authifier Shield API key
authifier_shield_key = "" authifier_shield_key = ""
# Legacy voice server management token
voso_legacy_token = "" voso_legacy_token = ""
# Whether services are behind the Cloudflare network
trust_cloudflare = false trust_cloudflare = false
[api.security.captcha] [api.security.captcha]
# hCaptcha configuration
hcaptcha_key = "" hcaptcha_key = ""
hcaptcha_sitekey = "" hcaptcha_sitekey = ""
[api.workers] [api.workers]
# Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50 max_concurrent_connections = 50
[api.livekit]
# Livekit server url
url = "ws://livekit:7880"
# Livekit security key name
key = "dev"
# Livekit security secret value
secret = "qweasdzxcqweasdzxcqweasdzxcqweasdzxcqweasdzxcqweasdzxc"
[pushd]
# this changes the names of the queues to not overlap
# prod/beta if they happen to be on the same exchange/instance.
# Usually they have to be, so that messages sent from one or the other get sent to everyone
production = true
# none of these should need changing
exchange = "revolt.notifications"
message_queue = "notifications.origin.message"
fr_accepted_queue = "notifications.ingest.fr_accepted" # friend request accepted
fr_received_queue = "notifications.ingest.fr_received" # friend request received
generic_queue = "notifications.ingest.generic" # generic messages (title + body)
ack_queue = "notifications.process.ack" # updates badges for apple devices
[pushd.vapid]
queue = "notifications.outbound.vapid"
private_key = "LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUJSUWpyTWxLRnBiVWhsUHpUbERvcEliYk1yeVNrNXpKYzVYVzIxSjJDS3hvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFWnkrQkg2TGJQZ2hEa3pEempXOG0rUXVPM3pCajRXT1phdkR6ZU00c0pqbmFwd1psTFE0WAp1ZDh2TzVodU94QWhMQlU3WWRldVovWHlBdFpWZmNyQi9BPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo"
public_key = "BGcvgR-i2z4IQ5Mw841vJvkLjt8wY-FjmWrw83jOLCY52qcGZS0OF7nfLzuYbjsQISwVO2HXrmf18gLWVX3Kwfw="
[pushd.fcm]
queue = "notifications.outbound.fcm"
key_type = ""
project_id = ""
private_key_id = ""
private_key = ""
client_email = ""
client_id = ""
auth_uri = ""
token_uri = ""
auth_provider_x509_cert_url = ""
client_x509_cert_url = ""
[pushd.apn]
sandbox = false
queue = "notifications.outbound.apn"
pkcs8 = ""
key_id = ""
team_id = ""
[files]
# Encryption key for stored files
# Generate your own key using `openssl rand -base64 32`
encryption_key = "qcuMA+ssxhMyKaNAKBGFfryfFtUH8NDlamQyDwGW6fU="
# Quality used for lossy WebP previews (set to 100 for lossless)
webp_quality = 80.0
# Mime types that cannot be uploaded or served
#
# Example for Windows executables and Android installation files:
# ["application/vnd.microsoft.portable-executable", "application/vnd.android.package-archive"]
blocked_mime_types = []
# ClamAV service
# hostname:port
clamd_host = ""
# Mime types that should be virus scanned
#
# Leave empty to scan all file types
scan_mime_types = [
"application/vnd.microsoft.portable-executable",
"application/vnd.android.package-archive",
"application/zip",
]
[files.limit]
# Minimum file size (in bytes)
min_file_size = 1
# Minimum image resolution
min_resolution = [1, 1]
# Maximum MP of images
max_mega_pixels = 40
# Maximum pixel side of an image
max_pixel_side = 10_000
[files.preview]
# Maximum image resolution
attachments = [1280, 1280]
avatars = [128, 128]
backgrounds = [1280, 720]
icons = [128, 128]
banners = [480, 480]
emojis = [128, 128]
[files.s3]
# Configuration for S3
# Defaults included for MinIO + self-hosted setup
#
# Backblaze B2:
# - endpoint is listed on the "Buckets" page
# - path_style_buckets is set to true
# - region is `eu-central-003` string from endpoint URL
# - access_key_id is keyID generated on the "Application Keys" page
# - secret_access_key is token generated on the "Application Keys" page
# - default_bucket matches the name of the bucket you've created
# S3 protocol endpoint
endpoint = "http://minio:9000"
# Whether to use path-style buckets
# Generally true, except for MinIO
path_style_buckets = false
# S3 region name
region = "minio"
# S3 protocol key ID
access_key_id = "minioautumn"
# S3 protocol access key
secret_access_key = "minioautumn"
default_bucket = "revolt-uploads"
[features] [features]
# Bucket to upload to by default
# Feature gate options
webhooks_enabled = false webhooks_enabled = false
[features.limits] [features.limits]
[features.limits.default] [features.limits.global]
group_size = 100 group_size = 100
bots = 5
message_length = 2000
message_embeds = 5 message_embeds = 5
message_replies = 5 message_replies = 5
message_attachments = 5
message_reactions = 20 message_reactions = 20
servers = 100
server_emoji = 100 server_emoji = 100
server_roles = 200 server_roles = 200
server_channels = 200 server_channels = 200
attachment_size = 20000000 # How many hours since creation a user is considered new
avatar_size = 4000000 new_user_hours = 72
background_size = 6000000
icon_size = 2500000 # Maximum permissible body size in bytes for uploads
banner_size = 6000000 # (should be greater than any one file upload limit)
emoji_size = 500000 body_limit_size = 20_000_000
[features.limits.new_user]
# Limits imposed on new users
# Number of outgoing friend requests permitted at any time
outgoing_friend_requests = 5
# Maximum number of owned bots
bots = 2
# Message content length
message_length = 2000
# Number of attachments that can be included
message_attachments = 5
# Maximum number of servers the user can create/join
servers = 50
[features.limits.new_user.file_upload_size_limit]
# Maximum file size limits (in bytes)
attachments = 20_000_000
avatars = 4_000_000
backgrounds = 6_000_000
icons = 2_500_000
banners = 6_000_000
emojis = 500_000
[features.limits.default]
# Limits imposed on users by default
# Number of outgoing friend requests permitted at any time
outgoing_friend_requests = 10
# Maximum number of owned bots
bots = 5
# Message content length
message_length = 2000
# Number of attachments that can be included
message_attachments = 5
# Maximum number of servers the user can create/join
servers = 100
[features.limits.default.file_upload_size_limit]
# Maximum file size limits (in bytes)
attachments = 20_000_000
avatars = 4_000_000
backgrounds = 6_000_000
icons = 2_500_000
banners = 6_000_000
emojis = 500_000
[features.advanced]
# The max amount of messages the rabbitmq provider/db mention adder job will delay for before forcing handling of a channel.
# default: 5
process_message_delay_limit = 5
[sentry] [sentry]
# Configuration for Sentry error reporting
api = "" api = ""
events = "" events = ""
voice_ingress = "" voice_ingress = ""
files = ""
proxy = ""

View File

@@ -6,9 +6,60 @@ use futures_locks::RwLock;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use serde::Deserialize; use serde::Deserialize;
#[cfg(not(debug_assertions))] pub use sentry::{capture_error, capture_message, Level};
use std::env;
#[cfg(feature = "report-macros")]
#[macro_export]
macro_rules! report_error {
( $expr: expr, $error: ident $( $tt:tt )? ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!($error))
};
}
#[cfg(feature = "report-macros")]
#[macro_export]
macro_rules! capture_internal_error {
( $expr: expr ) => {
$crate::capture_message(
&format!("{:?} ({}:{}:{})", $expr, file!(), line!(), column!()),
$crate::Level::Error,
);
};
}
#[cfg(feature = "report-macros")]
#[macro_export]
macro_rules! report_internal_error {
( $expr: expr ) => {
$expr
.inspect_err(|err| {
$crate::capture_message(
&format!("{err:?} ({}:{}:{})", file!(), line!(), column!()),
$crate::Level::Error,
);
})
.map_err(|_| ::revolt_result::create_error!(InternalError))
};
}
/// Paths to search for configuration
static CONFIG_SEARCH_PATHS: [&str; 3] = [
// current working directory
"Revolt.toml",
// current working directory - overrides file
"Revolt.overrides.toml",
// root directory, for Docker containers
"/Revolt.toml",
];
/// Configuration builder
static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| { static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::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(
@@ -21,22 +72,32 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
include_str!("../Revolt.test.toml"), include_str!("../Revolt.test.toml"),
FileFormat::Toml, FileFormat::Toml,
)); ));
} else if std::path::Path::new("Revolt.toml").exists() { }
builder = builder.add_source(File::new("Revolt.toml", FileFormat::Toml));
for path in CONFIG_SEARCH_PATHS {
if std::path::Path::new(path).exists() {
builder = builder.add_source(File::new(path, FileFormat::Toml));
}
} }
builder.build().unwrap() builder.build().unwrap()
}) })
}); });
// https://gifbox.me/view/gT5mqxYKCZv-twilight-meow
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Database { pub struct Database {
pub mongodb: String, pub mongodb: String,
pub redis: String, pub redis: String,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct Rabbit {
pub host: String,
pub port: u16,
pub username: String,
pub password: String,
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Hosts { pub struct Hosts {
pub app: String, pub app: String,
@@ -64,14 +125,34 @@ pub struct ApiSmtp {
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct ApiVapid { pub struct PushVapid {
pub queue: String,
pub private_key: String, pub private_key: String,
pub public_key: String, pub public_key: String,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct ApiFcm { pub struct PushFcm {
pub api_key: String, pub queue: String,
pub key_type: String,
pub project_id: String,
pub private_key_id: String,
pub private_key: String,
pub client_email: String,
pub client_id: String,
pub auth_uri: String,
pub token_uri: String,
pub auth_provider_x509_cert_url: String,
pub client_x509_cert_url: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct PushApn {
pub queue: String,
pub sandbox: bool,
pub pkcs8: String,
pub key_id: String,
pub team_id: String,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
@@ -104,61 +185,163 @@ pub struct ApiLiveKit {
pub struct Api { pub struct Api {
pub registration: ApiRegistration, pub registration: ApiRegistration,
pub smtp: ApiSmtp, pub smtp: ApiSmtp,
pub vapid: ApiVapid,
pub fcm: ApiFcm,
pub security: ApiSecurity, pub security: ApiSecurity,
pub workers: ApiWorkers, pub workers: ApiWorkers,
pub livekit: ApiLiveKit, pub livekit: ApiLiveKit,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits { pub struct Pushd {
pub production: bool,
pub exchange: String,
pub message_queue: String,
pub fr_accepted_queue: String,
pub fr_received_queue: String,
pub generic_queue: String,
pub ack_queue: String,
pub vapid: PushVapid,
pub fcm: PushFcm,
pub apn: PushApn,
}
impl Pushd {
fn get_routing_key(&self, key: String) -> String {
match self.production {
true => key + "-prd",
false => key + "-tst",
}
}
pub fn get_ack_routing_key(&self) -> String {
self.get_routing_key(self.ack_queue.clone())
}
pub fn get_message_routing_key(&self) -> String {
self.get_routing_key(self.message_queue.clone())
}
pub fn get_fr_accepted_routing_key(&self) -> String {
self.get_routing_key(self.fr_accepted_queue.clone())
}
pub fn get_fr_received_routing_key(&self) -> String {
self.get_routing_key(self.fr_received_queue.clone())
}
pub fn get_generic_routing_key(&self) -> String {
self.get_routing_key(self.generic_queue.clone())
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit {
pub min_file_size: usize,
pub min_resolution: [usize; 2],
pub max_mega_pixels: usize,
pub max_pixel_side: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesS3 {
pub endpoint: String,
pub path_style_buckets: bool,
pub region: String,
pub access_key_id: String,
pub secret_access_key: String,
pub default_bucket: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct Files {
pub encryption_key: String,
pub webp_quality: f32,
pub blocked_mime_types: Vec<String>,
pub clamd_host: String,
pub scan_mime_types: Vec<String>,
pub limit: FilesLimit,
pub preview: HashMap<String, [usize; 2]>,
pub s3: FilesS3,
}
#[derive(Deserialize, Debug, Clone)]
pub struct GlobalLimits {
pub group_size: usize, pub group_size: usize,
pub bots: usize,
pub message_length: usize,
pub message_replies: usize,
pub message_attachments: usize,
pub message_embeds: usize, pub message_embeds: usize,
pub message_replies: usize,
pub message_reactions: usize, pub message_reactions: usize,
pub servers: usize,
pub server_emoji: usize, pub server_emoji: usize,
pub server_roles: usize, pub server_roles: usize,
pub server_channels: usize, pub server_channels: usize,
pub attachment_size: usize, pub new_user_hours: usize,
pub avatar_size: usize,
pub background_size: usize, pub body_limit_size: usize,
pub icon_size: usize, }
pub banner_size: usize,
pub emoji_size: usize, #[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimits {
pub outgoing_friend_requests: usize,
pub bots: usize,
pub message_length: usize,
pub message_attachments: usize,
pub servers: usize,
pub file_upload_size_limit: HashMap<String, usize>,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct FeaturesLimitsCollection { pub struct FeaturesLimitsCollection {
pub global: GlobalLimits,
pub new_user: FeaturesLimits,
pub default: FeaturesLimits, pub default: FeaturesLimits,
#[serde(flatten)] #[serde(flatten)]
pub roles: HashMap<String, FeaturesLimits>, pub roles: HashMap<String, FeaturesLimits>,
} }
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced {
#[serde(default)]
pub process_message_delay_limit: u16,
}
impl Default for FeaturesAdvanced {
fn default() -> Self {
Self {
process_message_delay_limit: 5,
}
}
}
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Features { pub struct Features {
pub limits: FeaturesLimitsCollection, pub limits: FeaturesLimitsCollection,
pub webhooks_enabled: bool, pub webhooks_enabled: bool,
#[serde(default)]
pub advanced: FeaturesAdvanced,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Sentry { pub struct Sentry {
pub api: String, pub api: String,
pub events: String, pub events: String,
pub voice_ingress: String pub voice_ingress: String,
pub files: String,
pub proxy: String,
} }
#[derive(Deserialize, Debug, Clone)] #[derive(Deserialize, Debug, Clone)]
pub struct Settings { pub struct Settings {
pub database: Database, pub database: Database,
pub rabbit: Rabbit,
pub hosts: Hosts, pub hosts: Hosts,
pub api: Api, pub api: Api,
pub pushd: Pushd,
pub files: Files,
pub features: Features, pub features: Features,
pub sentry: Sentry, pub sentry: Sentry,
} }
@@ -166,22 +349,10 @@ pub struct Settings {
impl Settings { impl Settings {
pub fn preflight_checks(&self) { pub fn preflight_checks(&self) {
if self.api.smtp.host.is_empty() { if self.api.smtp.host.is_empty() {
#[cfg(not(debug_assertions))]
if !env::var("REVOLT_UNSAFE_NO_EMAIL").map_or(false, |v| v == *"1") {
panic!("Running in production without email is not recommended, set REVOLT_UNSAFE_NO_EMAIL=1 to override.");
}
#[cfg(debug_assertions)]
log::warn!("No SMTP settings specified! Remember to configure email."); log::warn!("No SMTP settings specified! Remember to configure email.");
} }
if self.api.security.captcha.hcaptcha_key.is_empty() { if self.api.security.captcha.hcaptcha_key.is_empty() {
#[cfg(not(debug_assertions))]
if !env::var("REVOLT_UNSAFE_NO_CAPTCHA").map_or(false, |v| v == *"1") {
panic!("Running in production without CAPTCHA is not recommended, set REVOLT_UNSAFE_NO_CAPTCHA=1 to override.");
}
#[cfg(debug_assertions)]
log::warn!("No Captcha key specified! Remember to add hCaptcha key."); log::warn!("No Captcha key specified! Remember to add hCaptcha key.");
} }
} }
@@ -205,8 +376,6 @@ pub async fn config() -> Settings {
/// Configure logging and common Rust variables /// Configure logging and common Rust variables
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> {
dotenv::dotenv().ok();
if std::env::var("RUST_LOG").is_err() { if std::env::var("RUST_LOG").is_err() {
std::env::set_var("RUST_LOG", "info"); std::env::set_var("RUST_LOG", "info");
} }
@@ -215,6 +384,12 @@ pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry:
std::env::set_var("ROCKET_ADDRESS", "0.0.0.0"); std::env::set_var("ROCKET_ADDRESS", "0.0.0.0");
} }
if std::env::var("REDIS_URL").is_err() {
// Configure redis-kiss library
let config = config().await;
std::env::set_var("REDIS_URI", config.database.redis);
}
pretty_env_logger::init(); pretty_env_logger::init();
log::info!("Starting {release}"); log::info!("Starting {release}");

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "revolt-database" name = "revolt-database"
version = "0.7.1" version = "0.8.1"
edition = "2021" edition = "2021"
license = "AGPL-3.0-or-later" license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"] authors = ["Paul Makles <me@insrt.uk>"]
@@ -16,6 +16,7 @@ mongodb = ["dep:mongodb", "bson"]
tasks = ["isahc", "linkify", "url-escape"] tasks = ["isahc", "linkify", "url-escape"]
async-std-runtime = ["async-std"] async-std-runtime = ["async-std"]
rocket-impl = ["rocket", "schemars", "revolt_okapi", "revolt_rocket_okapi"] rocket-impl = ["rocket", "schemars", "revolt_okapi", "revolt_rocket_okapi"]
axum-impl = ["axum"]
redis-is-patched = ["revolt-presence/redis-is-patched"] redis-is-patched = ["revolt-presence/redis-is-patched"]
# Default Features # Default Features
@@ -23,13 +24,15 @@ default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies] [dependencies]
# Core # Core
revolt-config = { version = "0.7.1", path = "../config" } revolt-config = { version = "0.8.1", path = "../config", features = [
revolt-result = { version = "0.7.1", path = "../result" } "report-macros",
revolt-models = { version = "0.7.1", path = "../models", features = [ ] }
revolt-result = { version = "0.8.1", path = "../result" }
revolt-models = { version = "0.8.1", path = "../models", features = [
"validator", "validator",
] } ] }
revolt-presence = { version = "0.7.1", path = "../presence" } revolt-presence = { version = "0.8.1", path = "../presence" }
revolt-permissions = { version = "0.7.1", path = "../permissions", features = [ revolt-permissions = { version = "0.8.1", path = "../permissions", features = [
"serde", "serde",
"bson", "bson",
] } ] }
@@ -76,17 +79,24 @@ async-recursion = "1.0.4"
# Async # Async
async-std = { version = "1.8.0", features = ["attributes"], optional = true } async-std = { version = "1.8.0", features = ["attributes"], optional = true }
# Axum Impl
axum = { version = "0.7.5", optional = true }
# Rocket Impl # Rocket Impl
schemars = { version = "0.8.8", optional = true } schemars = { version = "0.8.8", optional = true }
rocket = { version = "0.5.0-rc.2", default-features = false, features = [ rocket = { version = "0.5.1", default-features = false, features = [
"json", "json",
], optional = true } ], optional = true }
revolt_okapi = { version = "0.9.1", optional = true } revolt_okapi = { version = "0.9.1", optional = true }
revolt_rocket_okapi = { version = "0.9.1", optional = true } revolt_rocket_okapi = { version = "0.10.0", optional = true }
# Notifications # Notifications
fcm = "0.9.2" fcm_v1 = "0.3.0"
web-push = "0.10.0" web-push = "0.10.0"
revolt_a2 = { version = "0.10", default-features = false, features = ["ring"] }
# Authifier # Authifier
authifier = { version = "1.0.8" } authifier = { version = "1.0.9", features = ["rocket_impl"] }
# RabbitMQ
amqprs = { version = "1.7.0" }

View File

@@ -0,0 +1,211 @@
use std::collections::HashSet;
use crate::events::rabbit::*;
use crate::User;
use amqprs::channel::BasicPublishArguments;
use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError};
use amqprs::{BasicProperties, FieldTable};
use revolt_models::v0::PushNotification;
use revolt_presence::filter_online;
use serde_json::to_string;
#[derive(Clone)]
pub struct AMQP {
#[allow(unused)]
connection: Connection,
channel: Channel,
}
impl AMQP {
pub fn new(connection: Connection, channel: Channel) -> AMQP {
AMQP {
connection,
channel,
}
}
pub async fn friend_request_accepted(
&self,
accepted_request_user: &User,
sent_request_user: &User,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = FRAcceptedPayload {
accepted_user: accepted_request_user.to_owned(),
user: sent_request_user.id.clone(),
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending friend request accept payload on channel {}: {}",
config.pushd.get_fr_accepted_routing_key(),
payload
);
self.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_fr_accepted_routing_key(),
),
)
.await
}
pub async fn friend_request_received(
&self,
received_request_user: &User,
sent_request_user: &User,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = FRReceivedPayload {
from_user: sent_request_user.to_owned(),
user: received_request_user.id.clone(),
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending friend request received payload on channel {}: {}",
config.pushd.get_fr_received_routing_key(),
payload
);
self.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_fr_received_routing_key(),
),
)
.await
}
pub async fn generic_message(
&self,
user: &User,
title: String,
body: String,
icon: Option<String>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = GenericPayload {
title,
body,
icon,
user: user.to_owned(),
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending generic payload on channel {}: {}",
config.pushd.get_generic_routing_key(),
payload
);
self.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_generic_routing_key(),
),
)
.await
}
pub async fn message_sent(
&self,
recipients: Vec<String>,
payload: PushNotification,
) -> Result<(), AMQPError> {
if recipients.is_empty() {
return Ok(());
}
let config = revolt_config::config().await;
let online_ids = filter_online(&recipients).await;
let recipients = (&recipients.into_iter().collect::<HashSet<String>>() - &online_ids)
.into_iter()
.collect::<Vec<String>>();
let payload = MessageSentPayload {
notification: payload,
users: recipients,
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending message payload on channel {}: {}",
config.pushd.get_message_routing_key(),
payload
);
self.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.pushd.exchange,
&config.pushd.get_message_routing_key(),
),
)
.await
}
pub async fn ack_message(
&self,
user_id: String,
channel_id: String,
message_id: String,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = AckPayload {
user_id: user_id.clone(),
channel_id: channel_id.clone(),
message_id,
};
let payload = to_string(&payload).unwrap();
info!(
"Sending ack payload on channel {}: {}",
config.pushd.ack_queue, payload
);
let mut headers = FieldTable::new();
headers.insert(
"x-deduplication-header".try_into().unwrap(),
format!("{}-{}", &user_id, &channel_id).into(),
);
self.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
//.with_headers(headers)
.finish(),
payload.into(),
BasicPublishArguments::new(&config.pushd.exchange, &config.pushd.ack_queue),
)
.await
}
}

View File

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

View File

@@ -1,6 +1,14 @@
mod mongodb; mod mongodb;
mod reference; mod reference;
use authifier::config::Captcha;
use authifier::config::EmailVerificationConfig;
use authifier::config::ResolveIp;
use authifier::config::SMTPSettings;
use authifier::config::Shield;
use authifier::config::Template;
use authifier::config::Templates;
use authifier::Authifier;
use rand::Rng; use rand::Rng;
use revolt_config::config; use revolt_config::config;
@@ -87,3 +95,87 @@ 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 {
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@revolt.chat".into()),
),
port: config.api.smtp.port,
use_tls: config.api.smtp.use_tls,
},
expiry: Default::default(),
templates: Templates {
verify: Template {
title: "Verify your Revolt account.".into(),
text: include_str!("../../templates/verify.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: Some(include_str!("../../templates/verify.html").into()),
},
reset: Template {
title: "Reset your Revolt password.".into(),
text: include_str!("../../templates/reset.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(include_str!("../../templates/reset.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 {
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(),
Database::MongoDb(MongoDb(client, _)) => authifier::Database::MongoDb(
authifier::database::MongoDb(client.database("revolt")),
),
},
config: auth_config,
event_channel: Some(crate::tasks::authifier_relay::sender()),
}
}
}

View File

@@ -3,7 +3,7 @@ use std::{collections::HashMap, sync::Arc};
use futures::lock::Mutex; use futures::lock::Mutex;
use crate::{ use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, Invite, Member, Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
MemberCompositeKey, Message, RatelimitEvent, Report, Server, ServerBan, Snapshot, User, MemberCompositeKey, Message, RatelimitEvent, Report, Server, ServerBan, Snapshot, User,
UserSettings, Webhook, UserSettings, Webhook,
}; };
@@ -18,6 +18,7 @@ database_derived!(
pub channel_unreads: Arc<Mutex<HashMap<ChannelCompositeKey, ChannelUnread>>>, pub channel_unreads: Arc<Mutex<HashMap<ChannelCompositeKey, ChannelUnread>>>,
pub channel_webhooks: Arc<Mutex<HashMap<String, Webhook>>>, pub channel_webhooks: Arc<Mutex<HashMap<String, Webhook>>>,
pub emojis: Arc<Mutex<HashMap<String, Emoji>>>, pub emojis: Arc<Mutex<HashMap<String, Emoji>>>,
pub file_hashes: Arc<Mutex<HashMap<String, FileHash>>>,
pub files: Arc<Mutex<HashMap<String, File>>>, pub files: Arc<Mutex<HashMap<String, File>>>,
pub messages: Arc<Mutex<HashMap<String, Message>>>, pub messages: Arc<Mutex<HashMap<String, Message>>>,
pub ratelimit_events: Arc<Mutex<HashMap<String, RatelimitEvent>>>, pub ratelimit_events: Arc<Mutex<HashMap<String, RatelimitEvent>>>,

View File

@@ -1,25 +1,13 @@
use authifier::AuthifierEvent; use authifier::AuthifierEvent;
use revolt_result::Error;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use revolt_models::v0::{ use revolt_models::v0::{
AppendMessage, Channel, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, Report, Server, User, UserSettings, UserVoiceState, Webhook AppendMessage, Channel, ChannelUnread, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
}; };
use revolt_result::Error;
use crate::Database; use crate::Database;
/// WebSocket Client Errors
#[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(tag = "error")]
pub enum WebSocketError {
LabelMe,
InternalError { at: String },
InvalidSession,
OnboardingNotFinished,
AlreadyAuthenticated,
MalformedData { msg: String },
}
/// Ping Packet /// Ping Packet
#[derive(Serialize, Deserialize, Debug, Clone)] #[derive(Serialize, Deserialize, Debug, Clone)]
#[serde(untagged)] #[serde(untagged)]
@@ -28,12 +16,18 @@ pub enum Ping {
Number(usize), Number(usize),
} }
/// Untagged Error /// Fields provided in Ready payload
#[derive(Serialize)] #[derive(PartialEq)]
#[serde(untagged)] pub enum ReadyPayloadFields {
pub enum ErrorEvent { Users,
Error(WebSocketError), Servers,
APIError(Error), Channels,
Members,
Emoji,
VoiceStates,
UserSettings(Vec<String>),
ChannelUnreads,
} }
/// Protocol Events /// Protocol Events
@@ -42,17 +36,32 @@ pub enum ErrorEvent {
pub enum EventV1 { pub enum EventV1 {
/// Multiple events /// Multiple events
Bulk { v: Vec<EventV1> }, Bulk { v: Vec<EventV1> },
/// Error event
Error { data: Error },
/// Successfully authenticated /// Successfully authenticated
Authenticated, Authenticated,
/// Logged out
Logout,
/// Basic data to cache /// Basic data to cache
Ready { Ready {
users: Vec<User>, #[serde(skip_serializing_if = "Option::is_none")]
servers: Vec<Server>, users: Option<Vec<User>>,
channels: Vec<Channel>, #[serde(skip_serializing_if = "Option::is_none")]
members: Vec<Member>, servers: Option<Vec<Server>>,
emojis: Vec<Emoji>, #[serde(skip_serializing_if = "Option::is_none")]
voice_states: Vec<ChannelVoiceState> channels: Option<Vec<Channel>>,
#[serde(skip_serializing_if = "Option::is_none")]
members: Option<Vec<Member>>,
#[serde(skip_serializing_if = "Option::is_none")]
emojis: Option<Vec<Emoji>>,
#[serde(skip_serializing_if = "Option::is_none")]
voice_states: Option<Vec<ChannelVoiceState>>,
#[serde(skip_serializing_if = "Option::is_none")]
user_settings: Option<UserSettings>,
#[serde(skip_serializing_if = "Option::is_none")]
channel_unreads: Option<Vec<ChannelUnread>>,
}, },
/// Ping response /// Ping response
@@ -65,6 +74,8 @@ pub enum EventV1 {
id: String, id: String,
channel: String, channel: String,
data: PartialMessage, data: PartialMessage,
#[serde(default)]
clear: Vec<FieldsMessage>,
}, },
/// Append information to existing message /// Append information to existing message
@@ -115,6 +126,7 @@ pub enum EventV1 {
ServerUpdate { ServerUpdate {
id: String, id: String,
data: PartialServer, data: PartialServer,
#[serde(default)]
clear: Vec<FieldsServer>, clear: Vec<FieldsServer>,
}, },
@@ -125,6 +137,7 @@ pub enum EventV1 {
ServerMemberUpdate { ServerMemberUpdate {
id: MemberCompositeKey, id: MemberCompositeKey,
data: PartialMember, data: PartialMember,
#[serde(default)]
clear: Vec<FieldsMember>, clear: Vec<FieldsMember>,
}, },
@@ -132,13 +145,18 @@ pub enum EventV1 {
ServerMemberJoin { id: String, user: String }, ServerMemberJoin { id: String, user: String },
/// User left server /// User left server
ServerMemberLeave { id: String, user: String }, ServerMemberLeave {
id: String,
user: String,
reason: RemovalIntention,
},
/// Server role created or updated /// Server role created or updated
ServerRoleUpdate { ServerRoleUpdate {
id: String, id: String,
role_id: String, role_id: String,
data: PartialRole, data: PartialRole,
#[serde(default)]
clear: Vec<FieldsRole>, clear: Vec<FieldsRole>,
}, },
@@ -149,6 +167,7 @@ pub enum EventV1 {
UserUpdate { UserUpdate {
id: String, id: String,
data: PartialUser, data: PartialUser,
#[serde(default)]
clear: Vec<FieldsUser>, clear: Vec<FieldsUser>,
event_id: Option<String>, event_id: Option<String>,
}, },
@@ -183,6 +202,7 @@ pub enum EventV1 {
ChannelUpdate { ChannelUpdate {
id: String, id: String,
data: PartialChannel, data: PartialChannel,
#[serde(default)]
clear: Vec<FieldsChannel>, clear: Vec<FieldsChannel>,
}, },
@@ -260,7 +280,7 @@ impl EventV1 {
// TODO: this should be captured by member list in the future and not immediately fanned out to users // TODO: this should be captured by member list in the future and not immediately fanned out to users
if let Ok(members) = db.fetch_all_memberships(&id).await { if let Ok(members) = db.fetch_all_memberships(&id).await {
for member in members { for member in members {
self.clone().p(member.id.server).await; self.clone().server(member.id.server).await;
} }
} }
} }
@@ -270,6 +290,11 @@ impl EventV1 {
self.p(format!("{id}!")).await; self.p(format!("{id}!")).await;
} }
/// Publish server member event
pub async fn server(self, id: String) {
self.p(format!("{id}u")).await;
}
/// Publish internal global event /// Publish internal global event
pub async fn global(self) { pub async fn global(self) {
self.p("global".to_string()).await; self.p("global".to_string()).await;

View File

@@ -1,2 +1,3 @@
pub mod client; pub mod client;
pub mod rabbit;
pub mod server; pub mod server;

View File

@@ -0,0 +1,59 @@
use std::collections::HashMap;
use revolt_models::v0::PushNotification;
use serde::{Deserialize, Serialize};
use crate::User;
#[derive(Serialize, Deserialize)]
pub struct MessageSentPayload {
pub notification: PushNotification,
pub users: Vec<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct FRAcceptedPayload {
pub accepted_user: User,
pub user: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct FRReceivedPayload {
pub from_user: User,
pub user: String,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct GenericPayload {
pub title: String,
pub body: String,
pub icon: Option<String>,
pub user: User,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
#[allow(clippy::large_enum_variant)]
pub enum PayloadKind {
MessageNotification(PushNotification),
FRAccepted(FRAcceptedPayload),
FRReceived(FRReceivedPayload),
BadgeUpdate(usize),
Generic(GenericPayload),
}
#[derive(Serialize, Deserialize)]
pub struct PayloadToService {
pub notification: PayloadKind,
pub user_id: String,
pub session_id: String,
pub token: String,
pub extras: HashMap<String, String>,
}
#[derive(Serialize, Deserialize)]
pub struct AckPayload {
pub user_id: String,
pub channel_id: String,
pub message_id: String,
}

View File

@@ -8,5 +8,6 @@ pub enum ClientMessage {
Authenticate { token: String }, Authenticate { token: String },
BeginTyping { channel: String }, BeginTyping { channel: String },
EndTyping { channel: String }, EndTyping { channel: String },
Subscribe { server_id: String },
Ping { data: Ping, responded: Option<()> }, Ping { data: Ping, responded: Option<()> },
} }

View File

@@ -16,6 +16,8 @@ extern crate revolt_optional_struct;
#[macro_use] #[macro_use]
extern crate revolt_result; extern crate revolt_result;
pub use iso8601_timestamp;
#[cfg(feature = "mongodb")] #[cfg(feature = "mongodb")]
pub use mongodb; pub use mongodb;
@@ -23,6 +25,26 @@ pub use mongodb;
#[macro_use] #[macro_use]
extern crate bson; extern crate bson;
#[macro_export]
#[cfg(debug_assertions)]
macro_rules! query {
( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => {
Ok($self.$type($collection, $($rest),+).await.unwrap())
};
}
#[macro_export]
#[cfg(not(debug_assertions))]
macro_rules! query {
( $self: ident, $type: ident, $collection: expr, $($rest:expr),+ ) => {
$self.$type($collection, $($rest),+).await
.map_err(|err| {
revolt_config::capture_internal_error!(err);
create_database_error!(stringify!($type), $collection)
})
};
}
macro_rules! database_derived { macro_rules! database_derived {
( $( $item:item )+ ) => { ( $( $item:item )+ ) => {
$( $(
@@ -83,7 +105,15 @@ pub use models::*;
pub mod events; pub mod events;
pub mod tasks; pub mod tasks;
mod amqp;
pub use amqp::amqp::AMQP;
/// Utility function to check if a boolean value is false /// Utility function to check if a boolean value is false
pub fn if_false(t: &bool) -> bool { pub fn if_false(t: &bool) -> bool {
!t !t
} }
/// Utility function to check if an option doesnt contain true
pub fn if_option_false(t: &Option<bool>) -> bool {
t != &Some(true)
}

View File

@@ -56,6 +56,10 @@ pub async fn create_database(db: &MongoDb) {
.await .await
.expect("Failed to create attachments collection."); .expect("Failed to create attachments collection.");
db.create_collection("attachment_hashes", None)
.await
.expect("Failed to create attachment_hashes collection.");
db.create_collection("user_settings", None) db.create_collection("user_settings", None)
.await .await
.expect("Failed to create user_settings collection."); .expect("Failed to create user_settings collection.");
@@ -146,7 +150,14 @@ pub async fn create_database(db: &MongoDb) {
"author": 1_i32 "author": 1_i32
}, },
"name": "author" "name": "author"
} },
{
"key": {
"channel": 1_i32,
"pinned": 1_i32
},
"name": "channel_pinned_compound"
},
] ]
}, },
None, None,
@@ -202,6 +213,46 @@ pub async fn create_database(db: &MongoDb) {
.await .await
.expect("Failed to create server_members index."); .expect("Failed to create server_members index.");
db.run_command(
doc! {
"createIndexes": "attachments",
"indexes": [
{
"key": {
"hash": 1_i32
},
"name": "hash"
},
{
"key": {
"used_for.id": 1_i32
},
"name": "used_for_id"
}
]
},
None,
)
.await
.expect("Failed to create attachments index.");
db.run_command(
doc! {
"createIndexes": "attachment_hashes",
"indexes": [
{
"key": {
"processed_hash": 1_i32
},
"name": "processed_hash"
}
]
},
None,
)
.await
.expect("Failed to create attachment_hashes index.");
db.collection("migrations") db.collection("migrations")
.insert_one( .insert_one(
doc! { doc! {

View File

@@ -5,11 +5,13 @@ use crate::{
bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document}, bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document},
options::FindOptions, options::FindOptions,
}, },
MongoDb, DISCRIMINATOR_SEARCH_SPACE, AbstractChannels, AbstractServers, Channel, Invite, MongoDb, DISCRIMINATOR_SEARCH_SPACE,
}; };
use bson::oid::ObjectId;
use futures::StreamExt; use futures::StreamExt;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use revolt_permissions::DEFAULT_WEBHOOK_PERMISSIONS; use revolt_permissions::DEFAULT_WEBHOOK_PERMISSIONS;
use revolt_result::{Error, ErrorType};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use unicode_segmentation::UnicodeSegmentation; use unicode_segmentation::UnicodeSegmentation;
@@ -19,7 +21,7 @@ struct MigrationInfo {
revision: i32, revision: i32,
} }
pub const LATEST_REVISION: i32 = 27; pub const LATEST_REVISION: i32 = 31;
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");
@@ -984,25 +986,266 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
} }
if revision <= 26 { if revision <= 26 {
info!("Running migration [revision 26 / 17-04-2024]: Add `can_publish` and `can_receive` to members"); // Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
info!("Running migration [revision 26 / 15-05-2024]: fix invites being incorrectly serialized with wrong enum tagging.");
auto_derived!(
pub enum OldInvite {
Server {
#[serde(rename = "_id")]
code: String,
server: String,
creator: String,
channel: String,
},
Group {
#[serde(rename = "_id")]
code: String,
creator: String,
channel: String,
},
}
);
#[derive(serde::Serialize, serde::Deserialize)]
struct Outer {
_id: ObjectId,
#[serde(flatten)]
invite: OldInvite,
}
let invites = db
.db()
.collection::<Outer>("channel_invites")
.find(
doc! {
"type": { "$exists": false }
},
None,
)
.await
.expect("failed to find invites")
.filter_map(|s| async { s.ok() })
.collect::<Vec<Outer>>()
.await
.into_iter()
.map(|invite| match invite.invite {
OldInvite::Server {
code,
server,
creator,
channel,
} => Invite::Server {
code,
server,
creator,
channel,
},
OldInvite::Group {
code,
creator,
channel,
} => Invite::Group {
code,
creator,
channel,
},
})
.collect::<Vec<Invite>>();
if !invites.is_empty() {
db.db()
.collection("channel_invites")
.insert_many(invites, None)
.await
.expect("failed to insert corrected invite");
db.db()
.collection::<Outer>("channel_invites")
.delete_many(
doc! {
"type": { "$exists": false }
},
None,
)
.await
.expect("failed to find invites");
}
}
if revision <= 27 {
info!("Running migration [revision 27 / 21-07-2024]: create message pinned index.");
db.db()
.run_command(
doc! {
"createIndexes": "messages",
"indexes": [
{
"key": {
"channel": 1_i32,
"pinned": 1_i32
},
"name": "channel_pinned_compound"
}
]
},
None,
)
.await
.expect("Failed to create message index.");
}
if revision <= 28 {
info!("Running migration [revision 28 / 10-09-2024]: Add support for new Autumn.");
db.db()
.create_collection("attachment_hashes", None)
.await
.ok();
db.db()
.run_command(
doc! {
"createIndexes": "attachments",
"indexes": [
{
"key": {
"hash": 1_i32
},
"name": "hash"
}
]
},
None,
)
.await
.expect("Failed to create attachments index.");
db.db()
.run_command(
doc! {
"createIndexes": "attachment_hashes",
"indexes": [
{
"key": {
"processed_hash": 1_i32
},
"name": "processed_hash"
}
]
},
None,
)
.await
.expect("Failed to create attachment_hashes index.");
}
// Revision 29 omitted due to bug.
if revision <= 30 {
info!("Running migration [revision 30 / 29-09-2024]: Add index for used_for.id to attachments.");
db.db()
.run_command(
doc! {
"createIndexes": "attachments",
"indexes": [
{
"key": {
"used_for.id": 1_i32
},
"name": "used_for_id"
}
]
},
None,
)
.await
.expect("Failed to create attachments index.");
}
if revision <= 31 {
info!("Running migration [revision 31 / 31-10-2024]: Add creator_id to webhooks and delete those whose channels don't exist.");
#[derive(serde::Serialize, serde::Deserialize)]
struct WebhookShell {
_id: String,
channel_id: String,
}
let webhooks = db
.db()
.collection::<WebhookShell>("channel_webhooks")
.find(doc! {}, None)
.await
.expect("webhooks")
.filter_map(|s| async { s.ok() })
.collect::<Vec<WebhookShell>>()
.await;
for webhook in webhooks {
match db.fetch_channel(&webhook.channel_id).await {
Ok(channel) => {
let creator_id = match channel {
Channel::Group { owner, .. } => owner,
Channel::TextChannel { server, .. }
| Channel::VoiceChannel { server, .. } => {
let server = db.fetch_server(&server).await.expect("server");
server.owner
}
_ => unreachable!("not server or group channel!"),
};
db.db()
.collection::<Document>("channel_webhooks")
.update_one(
doc! {
"_id": webhook._id,
},
doc! {
"$set" : {
"creator_id": creator_id
}
},
None,
)
.await
.expect("update webhook");
}
Err(Error {
error_type: ErrorType::NotFound,
..
}) => {
db.db()
.collection::<WebhookShell>("channel_webhooks")
.delete_one(doc! { "_id": webhook._id }, None)
.await
.expect("failed to delete invalid webhook");
}
Err(err) => panic!("{err:?}"),
}
}
}
if revision <= 32 {
info!("Running migration [revision 32 / 26-01-2025]: Add `is_publishing` and `is_receiving` to members");
db.col::<Document>("server_members") db.col::<Document>("server_members")
.update_many( .update_many(
doc! {}, doc! {},
doc! { doc! {
"$set": { "$set": {
"can_publish": true, "is_publishing": true,
"can_receive": true "is_receiving": true
} }
}, },
None None,
) )
.await .await
.expect("Failed to update members"); .expect("Failed to update members");
} }
// Need to migrate fields on attachments, change `user_id`, `object_id`, etc to `parent`.
// 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

@@ -2,7 +2,7 @@ use revolt_config::config;
use revolt_result::Result; use revolt_result::Result;
use ulid::Ulid; use ulid::Ulid;
use crate::{BotInformation, Database, PartialUser, User}; use crate::{events::client::EventV1, BotInformation, Database, PartialUser, User};
auto_derived_partial!( auto_derived_partial!(
/// Bot /// Bot
@@ -72,7 +72,12 @@ impl Default for Bot {
#[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_methods)]
impl Bot { impl Bot {
/// Create a new bot /// Create a new bot
pub async fn create<D>(db: &Database, username: String, owner: &User, data: D) -> Result<Bot> pub async fn create<D>(
db: &Database,
username: String,
owner: &User,
data: D,
) -> Result<(Bot, User)>
where where
D: Into<Option<PartialBot>>, D: Into<Option<PartialBot>>,
{ {
@@ -80,14 +85,13 @@ impl Bot {
return Err(create_error!(IsBot)); return Err(create_error!(IsBot));
} }
let config = config().await; if db.get_number_of_bots_by_user(&owner.id).await? >= owner.limits().await.bots {
if db.get_number_of_bots_by_user(&owner.id).await? >= config.features.limits.default.bots {
return Err(create_error!(ReachedMaximumBots)); return Err(create_error!(ReachedMaximumBots));
} }
let id = Ulid::new().to_string(); let id = Ulid::new().to_string();
User::create( let user = User::create(
db, db,
username, username,
Some(id.to_string()), Some(id.to_string()),
@@ -112,7 +116,7 @@ impl Bot {
} }
db.insert_bot(&bot).await?; db.insert_bot(&bot).await?;
Ok(bot) Ok((bot, user))
} }
/// Remove a field from this object /// Remove a field from this object
@@ -142,6 +146,10 @@ impl Bot {
db.update_bot(&self.id, &partial, remove).await?; db.update_bot(&self.id, &partial, remove).await?;
if partial.token.is_some() {
EventV1::Logout.private(self.id.clone()).await;
}
self.apply_options(partial); self.apply_options(partial);
Ok(()) Ok(())
} }
@@ -164,7 +172,7 @@ mod tests {
.await .await
.unwrap(); .unwrap();
let bot = Bot::create( let (bot, _) = Bot::create(
&db, &db,
"Bot Name".to_string(), "Bot Name".to_string(),
&owner, &owner,

View File

@@ -10,6 +10,7 @@ static ALPHABET: [char; 54] = [
auto_derived!( auto_derived!(
/// Invite /// Invite
#[serde(tag = "type")]
pub enum Invite { pub enum Invite {
/// Invite to a specific server channel /// Invite to a specific server channel
Server { Server {

View File

@@ -7,13 +7,13 @@ mod reference;
#[async_trait] #[async_trait]
pub trait AbstractChannelUnreads: Sync + Send { pub trait AbstractChannelUnreads: Sync + Send {
/// Acknowledge a message. /// Acknowledge a message, and returns updated channel unread.
async fn acknowledge_message( async fn acknowledge_message(
&self, &self,
channel_id: &str, channel_id: &str,
user_id: &str, user_id: &str,
message_id: &str, message_id: &str,
) -> Result<()>; ) -> Result<Option<ChannelUnread>>;
/// Acknowledge many channels. /// Acknowledge many channels.
async fn acknowledge_channels(&self, user_id: &str, channel_ids: &[String]) -> Result<()>; async fn acknowledge_channels(&self, user_id: &str, channel_ids: &[String]) -> Result<()>;
@@ -26,6 +26,12 @@ pub trait AbstractChannelUnreads: Sync + Send {
message_ids: &[String], message_ids: &[String],
) -> Result<()>; ) -> Result<()>;
/// Fetch all unreads with mentions for a user.
async fn fetch_unread_mentions(&self, user_id: &str) -> Result<Vec<ChannelUnread>>;
/// Fetch all channel unreads for a user. /// Fetch all channel unreads for a user.
async fn fetch_unreads(&self, user_id: &str) -> Result<Vec<ChannelUnread>>; async fn fetch_unreads(&self, user_id: &str) -> Result<Vec<ChannelUnread>>;
/// Fetch unread for a specific user in a channel.
async fn fetch_unread(&self, user_id: &str, channel_id: &str) -> Result<Option<ChannelUnread>>;
} }

View File

@@ -1,4 +1,6 @@
use bson::Document; use bson::Document;
use mongodb::options::FindOneAndUpdateOptions;
use mongodb::options::ReturnDocument;
use mongodb::options::UpdateOptions; use mongodb::options::UpdateOptions;
use revolt_result::Result; use revolt_result::Result;
use ulid::Ulid; use ulid::Ulid;
@@ -12,31 +14,35 @@ static COL: &str = "channel_unreads";
#[async_trait] #[async_trait]
impl AbstractChannelUnreads for MongoDb { impl AbstractChannelUnreads for MongoDb {
/// Acknowledge a message. /// Acknowledge a message, and returns updated channel unread.
async fn acknowledge_message( async fn acknowledge_message(
&self, &self,
channel_id: &str, channel_id: &str,
user_id: &str, user_id: &str,
message_id: &str, message_id: &str,
) -> Result<()> { ) -> Result<Option<ChannelUnread>> {
self.col::<Document>(COL) self.col::<ChannelUnread>(COL)
.update_one( .find_one_and_update(
doc! { doc! {
"_id.channel": channel_id, "_id.channel": channel_id,
"_id.user": user_id, "_id.user": user_id,
}, },
doc! { doc! {
"$unset": { "$pull": {
"mentions": 1_i32 "mentions": {
"$lte": message_id
}
}, },
"$set": { "$set": {
"last_id": message_id "last_id": message_id
} }
}, },
UpdateOptions::builder().upsert(true).build(), FindOneAndUpdateOptions::builder()
.upsert(true)
.return_document(ReturnDocument::After)
.build(),
) )
.await .await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL)) .map_err(|_| create_database_error!("update_one", COL))
} }
@@ -116,4 +122,29 @@ impl AbstractChannelUnreads for MongoDb {
} }
) )
} }
async fn fetch_unread_mentions(&self, user_id: &str) -> Result<Vec<ChannelUnread>> {
query! {
self,
find,
COL,
doc! {
"_id.user": user_id,
"mentions": {"$ne": null}
}
}
}
/// Fetch unread for a specific user in a channel.
async fn fetch_unread(&self, user_id: &str, channel_id: &str) -> Result<Option<ChannelUnread>> {
query!(
self,
find_one,
COL,
doc! {
"_id.user": user_id,
"_id.channel": channel_id
}
)
}
} }

View File

@@ -13,7 +13,7 @@ impl AbstractChannelUnreads for ReferenceDb {
channel_id: &str, channel_id: &str,
user_id: &str, user_id: &str,
message_id: &str, message_id: &str,
) -> Result<()> { ) -> Result<Option<ChannelUnread>> {
let mut unreads = self.channel_unreads.lock().await; let mut unreads = self.channel_unreads.lock().await;
let key = ChannelCompositeKey { let key = ChannelCompositeKey {
channel: channel_id.to_string(), channel: channel_id.to_string(),
@@ -27,14 +27,14 @@ impl AbstractChannelUnreads for ReferenceDb {
unreads.insert( unreads.insert(
key.clone(), key.clone(),
ChannelUnread { ChannelUnread {
id: key, id: key.clone(),
last_id: Some(message_id.to_string()), last_id: Some(message_id.to_string()),
mentions: None, mentions: None,
}, },
); );
} }
Ok(()) Ok(unreads.get(&key).cloned())
} }
/// Acknowledge many channels. /// Acknowledge many channels.
@@ -78,6 +78,15 @@ impl AbstractChannelUnreads for ReferenceDb {
Ok(()) Ok(())
} }
async fn fetch_unread_mentions(&self, user_id: &str) -> Result<Vec<ChannelUnread>> {
let unreads = self.channel_unreads.lock().await;
Ok(unreads
.values()
.filter(|unread| unread.id.user == user_id && unread.mentions.is_some())
.cloned()
.collect())
}
/// Fetch all channel unreads for a user. /// Fetch all channel unreads for a user.
async fn fetch_unreads(&self, user_id: &str) -> Result<Vec<ChannelUnread>> { async fn fetch_unreads(&self, user_id: &str) -> Result<Vec<ChannelUnread>> {
let unreads = self.channel_unreads.lock().await; let unreads = self.channel_unreads.lock().await;
@@ -87,4 +96,16 @@ impl AbstractChannelUnreads for ReferenceDb {
.cloned() .cloned()
.collect()) .collect())
} }
/// Fetch unread for a specific user in a channel.
async fn fetch_unread(&self, user_id: &str, channel_id: &str) -> Result<Option<ChannelUnread>> {
let unreads = self.channel_unreads.lock().await;
Ok(unreads
.get(&ChannelCompositeKey {
channel: channel_id.to_string(),
user: user_id.to_string(),
})
.cloned())
}
} }

View File

@@ -17,6 +17,9 @@ auto_derived_partial!(
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub avatar: Option<File>, pub avatar: Option<File>,
/// User that created this webhook
pub creator_id: String,
/// The channel this webhook belongs to /// The channel this webhook belongs to
pub channel_id: String, pub channel_id: String,
@@ -43,6 +46,7 @@ impl Default for Webhook {
id: Default::default(), id: Default::default(),
name: Default::default(), name: Default::default(),
avatar: None, avatar: None,
creator_id: Default::default(),
channel_id: Default::default(), channel_id: Default::default(),
permissions: Default::default(), permissions: Default::default(),
token: Default::default(), token: Default::default(),
@@ -70,7 +74,7 @@ impl Webhook {
if self.token.as_deref() == Some(token) { if self.token.as_deref() == Some(token) {
Ok(()) Ok(())
} else { } else {
Err(create_error!(InvalidCredentials)) Err(create_error!(NotAuthenticated))
} }
} }

View File

@@ -9,7 +9,7 @@ use ulid::Ulid;
use crate::{ use crate::{
events::client::EventV1, tasks::ack::AckEvent, Database, File, IntoDocumentPath, PartialServer, events::client::EventV1, tasks::ack::AckEvent, Database, File, IntoDocumentPath, PartialServer,
Server, SystemMessage, User, Server, SystemMessage, User, AMQP,
}; };
auto_derived!( auto_derived!(
@@ -205,9 +205,9 @@ impl Channel {
update_server: bool, update_server: bool,
) -> Result<Channel> { ) -> Result<Channel> {
let config = config().await; let config = config().await;
if server.channels.len() > config.features.limits.default.server_channels { if server.channels.len() > config.features.limits.global.server_channels {
return Err(create_error!(TooManyChannels { return Err(create_error!(TooManyChannels {
max: config.features.limits.default.server_channels, max: config.features.limits.global.server_channels,
})); }));
}; };
@@ -268,9 +268,9 @@ impl Channel {
data.users.insert(owner_id.to_string()); data.users.insert(owner_id.to_string());
let config = config().await; let config = config().await;
if data.users.len() > config.features.limits.default.group_size { if data.users.len() > config.features.limits.global.group_size {
return Err(create_error!(GroupTooLarge { return Err(create_error!(GroupTooLarge {
max: config.features.limits.default.group_size, max: config.features.limits.global.group_size,
})); }));
} }
@@ -342,6 +342,7 @@ impl Channel {
pub async fn add_user_to_group( pub async fn add_user_to_group(
&mut self, &mut self,
db: &Database, db: &Database,
amqp: &AMQP,
user: &User, user: &User,
by_id: &str, by_id: &str,
) -> Result<()> { ) -> Result<()> {
@@ -351,9 +352,9 @@ impl Channel {
} }
let config = config().await; let config = config().await;
if recipients.len() >= config.features.limits.default.group_size { if recipients.len() >= config.features.limits.global.group_size {
return Err(create_error!(GroupTooLarge { return Err(create_error!(GroupTooLarge {
max: config.features.limits.default.group_size max: config.features.limits.global.group_size
})); }));
} }
@@ -378,10 +379,13 @@ impl Channel {
.into_message(id.to_string()) .into_message(id.to_string())
.send( .send(
db, db,
Some(amqp),
MessageAuthor::System { MessageAuthor::System {
username: &user.username, username: &user.username,
avatar: user.avatar.as_ref().map(|file| file.id.as_ref()), avatar: user.avatar.as_ref().map(|file| file.id.as_ref()),
}, },
None,
None,
self, self,
false, false,
) )
@@ -420,13 +424,13 @@ impl Channel {
} }
/// Clone this channel's id /// Clone this channel's id
pub fn id(&self) -> String { pub fn id(&self) -> &str {
match self { match self {
Channel::DirectMessage { id, .. } Channel::DirectMessage { id, .. }
| Channel::Group { id, .. } | Channel::Group { id, .. }
| Channel::SavedMessages { id, .. } | Channel::SavedMessages { id, .. }
| Channel::TextChannel { id, .. } | Channel::TextChannel { id, .. }
| Channel::VoiceChannel { id, .. } => id.clone(), | Channel::VoiceChannel { id, .. } => id,
} }
} }
@@ -659,7 +663,7 @@ impl Channel {
.private(user.to_string()) .private(user.to_string())
.await; .await;
crate::tasks::ack::queue( crate::tasks::ack::queue_ack(
self.id().to_string(), self.id().to_string(),
user.to_string(), user.to_string(),
AckEvent::AckMessage { AckEvent::AckMessage {
@@ -675,6 +679,7 @@ impl Channel {
pub async fn remove_user_from_group( pub async fn remove_user_from_group(
&self, &self,
db: &Database, db: &Database,
amqp: &AMQP,
user: &User, user: &User,
by_id: Option<&str>, by_id: Option<&str>,
silent: bool, silent: bool,
@@ -706,10 +711,13 @@ impl Channel {
.into_message(id.to_string()) .into_message(id.to_string())
.send( .send(
db, db,
Some(amqp),
MessageAuthor::System { MessageAuthor::System {
username: name, username: name,
avatar: None, avatar: None,
}, },
None,
None,
self, self,
false, false,
) )
@@ -720,6 +728,8 @@ impl Channel {
} }
} }
db.remove_user_from_group(id, &user.id).await?;
EventV1::ChannelGroupLeave { EventV1::ChannelGroupLeave {
id: id.to_string(), id: id.to_string(),
user: user.id.to_string(), user: user.id.to_string(),
@@ -741,10 +751,13 @@ impl Channel {
.into_message(id.to_string()) .into_message(id.to_string())
.send( .send(
db, db,
Some(amqp),
MessageAuthor::System { MessageAuthor::System {
username: &user.username, username: &user.username,
avatar: user.avatar.as_ref().map(|file| file.id.as_ref()), avatar: user.avatar.as_ref().map(|file| file.id.as_ref()),
}, },
None,
None,
self, self,
false, false,
) )

View File

@@ -261,12 +261,12 @@ impl AbstractChannels for MongoDb {
// Delete associated attachments // Delete associated attachments
self.delete_many_attachments(doc! { self.delete_many_attachments(doc! {
"object_id": &id "used_for.id": &id
}) })
.await?; .await?;
// Delete the channel itself // Delete the channel itself
query!(self, delete_one_by_id, COL, &channel.id()).map(|_| ()) query!(self, delete_one_by_id, COL, channel.id()).map(|_| ())
} }
} }

View File

@@ -11,7 +11,7 @@ impl AbstractChannels for ReferenceDb {
/// Insert a new channel in the database /// Insert a new channel in the database
async fn insert_channel(&self, channel: &Channel) -> Result<()> { async fn insert_channel(&self, channel: &Channel) -> Result<()> {
let mut channels = self.channels.lock().await; let mut channels = self.channels.lock().await;
if let Entry::Vacant(entry) = channels.entry(channel.id()) { if let Entry::Vacant(entry) = channels.entry(channel.id().to_string()) {
entry.insert(channel.clone()); entry.insert(channel.clone());
Ok(()) Ok(())
} else { } else {
@@ -148,7 +148,7 @@ impl AbstractChannels for ReferenceDb {
// 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;
if channels.remove(&channel.id()).is_some() { if channels.remove(channel.id()).is_some() {
Ok(()) Ok(())
} else { } else {
Err(create_error!(NotFound)) Err(create_error!(NotFound))

View File

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

View File

@@ -0,0 +1,92 @@
use iso8601_timestamp::Timestamp;
use crate::File;
auto_derived_partial!(
/// File hash
pub struct FileHash {
/// Sha256 hash of the file
#[serde(rename = "_id")]
pub id: String,
/// Sha256 hash of file after it has been processed
pub processed_hash: String,
/// When this file was created in system
pub created_at: Timestamp,
/// The bucket this file is stored in
pub bucket_id: String,
/// The path at which this file exists in
pub path: String,
/// Cryptographic nonce used to encrypt this file
pub iv: String,
/// Parsed metadata of this file
pub metadata: Metadata,
/// Raw content type of this file
pub content_type: String,
/// Size of this file (in bytes)
pub size: isize,
},
"PartialFile"
);
auto_derived!(
/// Metadata associated with a file
#[serde(tag = "type")]
#[derive(Default)]
pub enum Metadata {
/// File is just a generic uncategorised file
#[default]
File,
/// File contains textual data and should be displayed as such
Text,
/// File is an image with specific dimensions
Image {
width: isize,
height: isize,
// animated: bool // TODO: https://docs.rs/image/latest/image/trait.AnimationDecoder.html for APNG support
},
/// File is a video with specific dimensions
Video { width: isize, height: isize },
/// File is audio
Audio,
}
);
impl FileHash {
/// Create a file from a file hash
pub fn into_file(
&self,
id: String,
tag: String,
filename: String,
uploader_id: String,
) -> File {
File {
id,
tag,
filename,
hash: Some(self.id.clone()),
uploaded_at: Some(Timestamp::now_utc()),
uploader_id: Some(uploader_id),
used_for: None,
deleted: None,
reported: None,
// TODO: remove this data
metadata: self.metadata.clone(),
content_type: self.content_type.clone(),
size: self.size,
// TODO: superseded by "used_for"
message_id: None,
object_id: None,
server_id: None,
user_id: None,
}
}
}

View File

@@ -0,0 +1,18 @@
use revolt_result::Result;
use crate::FileHash;
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractAttachmentHashes: Sync + Send {
/// Insert a new attachment hash into the database.
async fn insert_attachment_hash(&self, hash: &FileHash) -> Result<()>;
/// Fetch an attachment hash entry by sha256 hash.
async fn fetch_attachment_hash(&self, hash: &str) -> Result<FileHash>;
/// Update an attachment hash nonce value.
async fn set_attachment_hash_nonce(&self, hash: &str, nonce: &str) -> Result<()>;
}

View File

@@ -0,0 +1,51 @@
use revolt_result::Result;
use crate::FileHash;
use crate::MongoDb;
use super::AbstractAttachmentHashes;
static COL: &str = "attachment_hashes";
#[async_trait]
impl AbstractAttachmentHashes for MongoDb {
/// Insert a new attachment hash into the database.
async fn insert_attachment_hash(&self, hash: &FileHash) -> Result<()> {
query!(self, insert_one, COL, &hash).map(|_| ())
}
/// Fetch an attachment hash entry by sha256 hash.
async fn fetch_attachment_hash(&self, hash: &str) -> Result<FileHash> {
query!(
self,
find_one,
COL,
doc! {
"$or": [
{"_id": hash},
{"processed_hash": hash}
]
}
)?
.ok_or_else(|| create_error!(NotFound))
}
/// Update an attachment hash nonce value.
async fn set_attachment_hash_nonce(&self, hash: &str, nonce: &str) -> Result<()> {
self.col::<FileHash>(COL)
.update_one(
doc! {
"_id": hash
},
doc! {
"$set": {
"iv": nonce
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
}

View File

@@ -0,0 +1,41 @@
use revolt_result::Result;
use crate::FileHash;
use crate::ReferenceDb;
use super::AbstractAttachmentHashes;
#[async_trait]
impl AbstractAttachmentHashes for ReferenceDb {
/// Insert a new attachment hash into the database.
async fn insert_attachment_hash(&self, hash: &FileHash) -> Result<()> {
let mut hashes = self.file_hashes.lock().await;
if hashes.contains_key(&hash.id) {
Err(create_database_error!("insert", "attachment"))
} else {
hashes.insert(hash.id.to_string(), hash.clone());
Ok(())
}
}
/// Fetch an attachment hash entry by sha256 hash.
async fn fetch_attachment_hash(&self, hash_value: &str) -> Result<FileHash> {
let hashes = self.file_hashes.lock().await;
hashes
.values()
.cloned()
.find(|hash| hash.id == hash_value || hash.processed_hash == hash_value)
.ok_or(create_error!(NotFound))
}
/// Update an attachment hash nonce value.
async fn set_attachment_hash_nonce(&self, hash: &str, nonce: &str) -> Result<()> {
let mut hashes = self.file_hashes.lock().await;
if let Some(file) = hashes.get_mut(hash) {
file.iv = nonce.to_owned();
Ok(())
} else {
Err(create_error!(NotFound))
}
}
}

View File

@@ -1,5 +1,6 @@
use crate::Database; use crate::{Database, FileHash, Metadata};
use iso8601_timestamp::Timestamp;
use revolt_result::Result; use revolt_result::Result;
auto_derived_partial!( auto_derived_partial!(
@@ -12,12 +13,18 @@ auto_derived_partial!(
pub tag: String, pub tag: String,
/// Original filename /// Original filename
pub filename: String, pub filename: String,
/// Parsed metadata of this file /// Hash of this file
pub metadata: Metadata, pub hash: Option<String>, // these are Option<>s to not break file uploads on legacy Autumn
/// Raw content type of this file
pub content_type: String, /// When this file was uploaded
/// Size of this file (in bytes) pub uploaded_at: Option<Timestamp>, // these are Option<>s to not break file uploads on legacy Autumn
pub size: isize, /// ID of user who uploaded this file
#[serde(skip_serializing_if = "Option::is_none")]
pub uploader_id: Option<String>, // these are Option<>s to not break file uploads on legacy Autumn
/// What the file was used for
#[serde(skip_serializing_if = "Option::is_none")]
pub used_for: Option<FileUsedFor>,
/// Whether this file was deleted /// Whether this file was deleted
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
@@ -26,6 +33,14 @@ auto_derived_partial!(
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub reported: Option<bool>, pub reported: Option<bool>,
// !!! DEPRECATED:
/// Parsed metadata of this file
pub metadata: Metadata,
/// Raw content type of this file
pub content_type: String,
/// Size of this file (in bytes)
pub size: isize,
// TODO: migrate this mess to having: // TODO: migrate this mess to having:
// - author_id // - author_id
// - parent: Parent { Message(id), User(id), etc } // - parent: Parent { Message(id), User(id), etc }
@@ -44,64 +59,184 @@ auto_derived_partial!(
); );
auto_derived!( auto_derived!(
/// Metadata associated with a file /// Type of object file was used for
#[serde(tag = "type")] pub enum FileUsedForType {
#[derive(Default)] Message,
pub enum Metadata { ServerBanner,
/// File is just a generic uncategorised file Emoji,
#[default] UserAvatar,
File, WebhookAvatar,
/// File contains textual data and should be displayed as such UserProfileBackground,
Text, LegacyGroupIcon,
/// File is an image with specific dimensions ChannelIcon,
Image { width: isize, height: isize }, ServerIcon,
/// File is a video with specific dimensions }
Video { width: isize, height: isize },
/// File is audio /// Information about what the file was used for
Audio, pub struct FileUsedFor {
/// Type of the object
#[serde(rename = "type")]
pub object_type: FileUsedForType,
/// ID of the object
pub id: String,
} }
); );
impl File { impl File {
/// Get the hash entry for this file
pub async fn as_hash(&self, db: &Database) -> Result<FileHash> {
db.fetch_attachment_hash(self.hash.as_ref().unwrap()).await
}
/// Use a file for a message attachment /// Use a file for a message attachment
pub async fn use_attachment(db: &Database, id: &str, parent: &str) -> Result<File> { pub async fn use_attachment(
db.find_and_use_attachment(id, "attachments", "message", parent) db: &Database,
.await id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"attachments",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::Message,
},
uploader_id.to_owned(),
)
.await
} }
/// Use a file for a user profile background /// Use a file for a user profile background
pub async fn use_background(db: &Database, id: &str, parent: &str) -> Result<File> { pub async fn use_background(
db.find_and_use_attachment(id, "backgrounds", "user", parent) db: &Database,
.await id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"backgrounds",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::UserProfileBackground,
},
uploader_id.to_owned(),
)
.await
} }
/// Use a file for a user avatar /// Use a file for a user avatar
pub async fn use_avatar(db: &Database, id: &str, parent: &str) -> Result<File> { pub async fn use_user_avatar(
db.find_and_use_attachment(id, "avatars", "user", parent) db: &Database,
.await id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"avatars",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::UserAvatar,
},
uploader_id.to_owned(),
)
.await
} }
/// Use a file for an icon /// Use a file for a webhook avatar
pub async fn use_icon(db: &Database, id: &str, parent: &str) -> Result<File> { pub async fn use_webhook_avatar(
db.find_and_use_attachment(id, "icons", "object", parent) db: &Database,
.await id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"avatars",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::WebhookAvatar,
},
uploader_id.to_owned(),
)
.await
} }
/// Use a file for a server icon /// Use a file for a server icon
pub async fn use_server_icon(db: &Database, id: &str, parent: &str) -> Result<File> { pub async fn use_server_icon(
db.find_and_use_attachment(id, "icons", "object", parent) db: &Database,
.await id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"icons",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::ServerIcon,
},
uploader_id.to_owned(),
)
.await
}
/// Use a file for a channel icon
pub async fn use_channel_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::ChannelIcon,
},
uploader_id.to_owned(),
)
.await
} }
/// Use a file for a server banner /// Use a file for a server banner
pub async fn use_banner(db: &Database, id: &str, parent: &str) -> Result<File> { pub async fn use_server_banner(
db.find_and_use_attachment(id, "banners", "server", parent) db: &Database,
.await id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"banners",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::ServerBanner,
},
uploader_id.to_owned(),
)
.await
} }
/// Use a file for an emoji /// Use a file for an emoji
pub async fn use_emoji(db: &Database, id: &str, parent: &str) -> Result<File> { pub async fn use_emoji(
db.find_and_use_attachment(id, "emojis", "object", parent) db: &Database,
.await id: &str,
parent: &str,
uploader_id: &str,
) -> Result<File> {
db.find_and_use_attachment(
id,
"emojis",
FileUsedFor {
id: parent.to_owned(),
object_type: FileUsedForType::Emoji,
},
uploader_id.to_owned(),
)
.await
} }
} }

View File

@@ -2,6 +2,8 @@ use revolt_result::Result;
use crate::File; use crate::File;
use super::FileUsedFor;
mod mongodb; mod mongodb;
mod reference; mod reference;
@@ -10,13 +12,16 @@ pub trait AbstractAttachments: Sync + Send {
/// Insert attachment into database. /// Insert attachment into database.
async fn insert_attachment(&self, attachment: &File) -> Result<()>; async fn insert_attachment(&self, attachment: &File) -> Result<()>;
/// Fetch an attachment by its id.
async fn fetch_attachment(&self, tag: &str, file_id: &str) -> Result<File>;
/// Find an attachment by its details and mark it as used by a given parent. /// Find an attachment by its details and mark it as used by a given parent.
async fn find_and_use_attachment( async fn find_and_use_attachment(
&self, &self,
id: &str, id: &str,
tag: &str, tag: &str,
parent_type: &str, used_for: FileUsedFor,
parent_id: &str, uploader_id: String,
) -> Result<File>; ) -> Result<File>;
/// Mark an attachment as having been reported. /// Mark an attachment as having been reported.

View File

@@ -1,7 +1,10 @@
use bson::to_document;
use bson::Document; use bson::Document;
use revolt_config::report_internal_error;
use revolt_result::Result; use revolt_result::Result;
use crate::File; use crate::File;
use crate::FileUsedFor;
use crate::MongoDb; use crate::MongoDb;
use super::AbstractAttachments; use super::AbstractAttachments;
@@ -15,15 +18,28 @@ impl AbstractAttachments for MongoDb {
query!(self, insert_one, COL, &attachment).map(|_| ()) query!(self, insert_one, COL, &attachment).map(|_| ())
} }
/// Fetch an attachment by its id.
async fn fetch_attachment(&self, tag: &str, file_id: &str) -> Result<File> {
query!(
self,
find_one,
COL,
doc! {
"_id": file_id,
"tag": tag
}
)?
.ok_or_else(|| create_error!(NotFound))
}
/// Find an attachment by its details and mark it as used by a given parent. /// Find an attachment by its details and mark it as used by a given parent.
async fn find_and_use_attachment( async fn find_and_use_attachment(
&self, &self,
id: &str, id: &str,
tag: &str, tag: &str,
parent_type: &str, used_for: FileUsedFor,
parent_id: &str, uploader_id: String,
) -> Result<File> { ) -> Result<File> {
let key = format!("{parent_type}_id");
let file = query!( let file = query!(
self, self,
find_one, find_one,
@@ -31,7 +47,7 @@ impl AbstractAttachments for MongoDb {
doc! { doc! {
"_id": id, "_id": id,
"tag": tag, "tag": tag,
&key: { "used_for": {
"$exists": false "$exists": false
} }
} }
@@ -45,7 +61,8 @@ impl AbstractAttachments for MongoDb {
}, },
doc! { doc! {
"$set": { "$set": {
key: parent_id "used_for": report_internal_error!(to_document(&used_for))?,
"uploader_id": uploader_id
} }
}, },
None, None,

View File

@@ -1,6 +1,7 @@
use revolt_result::Result; use revolt_result::Result;
use crate::File; use crate::File;
use crate::FileUsedFor;
use crate::ReferenceDb; use crate::ReferenceDb;
use super::AbstractAttachments; use super::AbstractAttachments;
@@ -18,24 +19,33 @@ impl AbstractAttachments for ReferenceDb {
} }
} }
/// Fetch an attachment by its id.
async fn fetch_attachment(&self, tag: &str, file_id: &str) -> Result<File> {
let files = self.files.lock().await;
if let Some(file) = files.get(file_id) {
if file.tag == tag {
Ok(file.clone())
} else {
Err(create_error!(NotFound))
}
} else {
Err(create_error!(NotFound))
}
}
/// Find an attachment by its details and mark it as used by a given parent. /// Find an attachment by its details and mark it as used by a given parent.
async fn find_and_use_attachment( async fn find_and_use_attachment(
&self, &self,
id: &str, id: &str,
tag: &str, tag: &str,
parent_type: &str, used_for: FileUsedFor,
parent_id: &str, uploader_id: String,
) -> Result<File> { ) -> Result<File> {
let mut files = self.files.lock().await; let mut files = self.files.lock().await;
if let Some(file) = files.get_mut(id) { if let Some(file) = files.get_mut(id) {
if file.tag == tag { if file.tag == tag {
match parent_type { file.uploader_id = Some(uploader_id);
"message" => file.message_id = Some(parent_id.to_owned()), file.used_for = Some(used_for);
"user" => file.user_id = Some(parent_id.to_owned()),
"object" => file.object_id = Some(parent_id.to_owned()),
"server" => file.server_id = Some(parent_id.to_owned()),
_ => unreachable!(),
}
Ok(file.clone()) Ok(file.clone())
} else { } else {

View File

@@ -1,11 +1,11 @@
use std::collections::HashSet; use std::{collections::HashSet, hash::RandomState};
use indexmap::{IndexMap, IndexSet}; use indexmap::{IndexMap, IndexSet};
use iso8601_timestamp::Timestamp; use iso8601_timestamp::Timestamp;
use revolt_config::config; use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{ use revolt_models::v0::{
self, BulkMessageResponse, DataMessageSend, Embed, MessageAuthor, MessageSort, MessageWebhook, self, BulkMessageResponse, DataMessageSend, Embed, MessageAuthor, MessageFlags, MessageSort,
PushNotification, ReplyIntent, SendableEmbed, Text, RE_MENTION, MessageWebhook, PushNotification, ReplyIntent, SendableEmbed, Text, RE_MENTION,
}; };
use revolt_permissions::{ChannelPermission, PermissionValue}; use revolt_permissions::{ChannelPermission, PermissionValue};
use revolt_result::Result; use revolt_result::Result;
@@ -15,8 +15,8 @@ use validator::Validate;
use crate::{ use crate::{
events::client::EventV1, events::client::EventV1,
tasks::{self, ack::AckEvent}, tasks::{self, ack::AckEvent},
util::idempotency::IdempotencyKey, util::{bulk_permissions::BulkDatabasePermissionQuery, idempotency::IdempotencyKey},
Channel, Database, Emoji, File, User, Channel, Database, Emoji, File, User, AMQP,
}; };
auto_derived_partial!( auto_derived_partial!(
@@ -65,6 +65,13 @@ auto_derived_partial!(
/// Name and / or avatar overrides for this message /// Name and / or avatar overrides for this message
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub masquerade: Option<Masquerade>, pub masquerade: Option<Masquerade>,
/// Whether or not the message in pinned
#[serde(skip_serializing_if = "crate::if_option_false")]
pub pinned: Option<bool>,
/// Bitfield of message flags
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<i32>,
}, },
"PartialMessage" "PartialMessage"
); );
@@ -95,6 +102,10 @@ auto_derived!(
ChannelIconChanged { by: String }, ChannelIconChanged { by: String },
#[serde(rename = "channel_ownership_changed")] #[serde(rename = "channel_ownership_changed")]
ChannelOwnershipChanged { from: String, to: String }, ChannelOwnershipChanged { from: String, to: String },
#[serde(rename = "message_pinned")]
MessagePinned { id: String, by: String },
#[serde(rename = "message_unpinned")]
MessageUnpinned { id: String, by: String },
} }
/// Name and / or avatar override information /// Name and / or avatar override information
@@ -164,6 +175,8 @@ auto_derived!(
pub author: Option<String>, pub author: Option<String>,
/// Search query /// Search query
pub query: Option<String>, pub query: Option<String>,
/// Search for pinned
pub pinned: Option<bool>,
} }
/// Message Query /// Message Query
@@ -179,6 +192,11 @@ auto_derived!(
#[serde(flatten)] #[serde(flatten)]
pub time_period: MessageTimePeriod, pub time_period: MessageTimePeriod,
} }
/// Optional fields on message
pub enum FieldsMessage {
Pinned,
}
); );
#[allow(clippy::derivable_impls)] #[allow(clippy::derivable_impls)]
@@ -200,6 +218,8 @@ impl Default for Message {
reactions: Default::default(), reactions: Default::default(),
interactions: Default::default(), interactions: Default::default(),
masquerade: None, masquerade: None,
flags: None,
pinned: None,
} }
} }
} }
@@ -207,11 +227,16 @@ impl Default for Message {
#[allow(clippy::disallowed_methods)] #[allow(clippy::disallowed_methods)]
impl Message { impl Message {
/// Create message from API data /// Create message from API data
#[allow(clippy::too_many_arguments)]
pub async fn create_from_api( pub async fn create_from_api(
db: &Database, db: &Database,
amqp: Option<&AMQP>,
channel: Channel, channel: Channel,
data: DataMessageSend, data: DataMessageSend,
author: MessageAuthor<'_>, author: MessageAuthor<'_>,
user: Option<v0::User>,
member: Option<v0::Member>,
limits: FeaturesLimits,
mut idempotency: IdempotencyKey, mut idempotency: IdempotencyKey,
generate_embeds: bool, generate_embeds: bool,
allow_mentions: bool, allow_mentions: bool,
@@ -221,7 +246,7 @@ impl Message {
Message::validate_sum( Message::validate_sum(
&data.content, &data.content,
data.embeds.as_deref().unwrap_or_default(), data.embeds.as_deref().unwrap_or_default(),
config.features.limits.default.message_length, limits.message_length,
)?; )?;
idempotency idempotency
@@ -237,6 +262,13 @@ impl Message {
return Err(create_error!(EmptyMessage)); return Err(create_error!(EmptyMessage));
} }
// Ensure flags are either not set or have permissible values
if let Some(flags) = &data.flags {
if flags != &0 && flags != &1 {
return Err(create_error!(InvalidProperty));
}
}
// Ensure restrict_reactions is not specified without reactions list // Ensure restrict_reactions is not specified without reactions list
if let Some(interactions) = &data.interactions { if let Some(interactions) = &data.interactions {
if interactions.restrict_reactions { if interactions.restrict_reactions {
@@ -262,7 +294,7 @@ impl Message {
let message_id = Ulid::new().to_string(); let message_id = Ulid::new().to_string();
let mut message = Message { let mut message = Message {
id: message_id.clone(), id: message_id.clone(),
channel: channel.id(), channel: channel.id().to_string(),
masquerade: data.masquerade.map(|masquerade| masquerade.into()), masquerade: data.masquerade.map(|masquerade| masquerade.into()),
interactions: data interactions: data
.interactions .interactions
@@ -270,6 +302,7 @@ impl Message {
.unwrap_or_default(), .unwrap_or_default(),
author: author_id, author: author_id,
webhook: webhook.map(|w| w.into()), webhook: webhook.map(|w| w.into()),
flags: data.flags.map(|v| v as i32),
..Default::default() ..Default::default()
}; };
@@ -288,9 +321,9 @@ impl Message {
// Verify replies are valid. // Verify replies are valid.
let mut replies = HashSet::new(); let mut replies = HashSet::new();
if let Some(entries) = data.replies { if let Some(entries) = data.replies {
if entries.len() > config.features.limits.default.message_replies { if entries.len() > config.features.limits.global.message_replies {
return Err(create_error!(TooManyReplies { return Err(create_error!(TooManyReplies {
max: config.features.limits.default.message_replies, max: config.features.limits.global.message_replies,
})); }));
} }
@@ -305,6 +338,50 @@ impl Message {
} }
} }
// Validate the mentions go to users in the channel/server
if !mentions.is_empty() {
match channel {
Channel::DirectMessage { ref recipients, .. }
| Channel::Group { ref recipients, .. } => {
let recipients_hash: HashSet<&String, RandomState> =
HashSet::from_iter(recipients);
mentions.retain(|m| recipients_hash.contains(m));
}
Channel::TextChannel { ref server, .. }
| Channel::VoiceChannel { ref server, .. } => {
let mentions_vec = Vec::from_iter(mentions.iter().cloned());
let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await;
if let Ok(valid_members) = valid_members {
let valid_mentions: HashSet<&String, RandomState> =
HashSet::from_iter(valid_members.iter().map(|m| &m.id.user));
mentions.retain(|m| valid_mentions.contains(m)); // quick pass, validate mentions are in the server
if !mentions.is_empty() {
// if there are still mentions, drill down to a channel-level
let member_channel_view_perms =
BulkDatabasePermissionQuery::from_server_id(db, server)
.await
.channel(&channel)
.members(&valid_members)
.members_can_see_channel()
.await;
mentions
.retain(|m| *member_channel_view_perms.get(m).unwrap_or(&false));
}
} else {
revolt_config::capture_error(&valid_members.unwrap_err());
return Err(create_error!(InternalError));
}
}
Channel::SavedMessages { .. } => {
mentions.clear();
}
}
}
if !mentions.is_empty() { if !mentions.is_empty() {
message.mentions.replace(mentions.into_iter().collect()); message.mentions.replace(mentions.into_iter().collect());
} }
@@ -320,28 +397,26 @@ impl Message {
if data if data
.attachments .attachments
.as_ref() .as_ref()
.is_some_and(|v| v.len() > config.features.limits.default.message_attachments) .is_some_and(|v| v.len() > limits.message_attachments)
{ {
return Err(create_error!(TooManyAttachments { return Err(create_error!(TooManyAttachments {
max: config.features.limits.default.message_attachments, max: limits.message_attachments,
})); }));
} }
if data if data
.embeds .embeds
.as_ref() .as_ref()
.is_some_and(|v| v.len() > config.features.limits.default.message_embeds) .is_some_and(|v| v.len() > config.features.limits.global.message_embeds)
{ {
return Err(create_error!(TooManyEmbeds { return Err(create_error!(TooManyEmbeds {
max: config.features.limits.default.message_embeds, max: config.features.limits.global.message_embeds,
})); }));
} }
for attachment_id in data.attachments.as_deref().unwrap_or_default() { for attachment_id in data.attachments.as_deref().unwrap_or_default() {
attachments.push( attachments
db.find_and_use_attachment(attachment_id, "attachments", "message", &message_id) .push(File::use_attachment(db, attachment_id, &message_id, author.id()).await?);
.await?,
);
} }
if !attachments.is_empty() { if !attachments.is_empty() {
@@ -360,7 +435,9 @@ impl Message {
message.nonce = Some(idempotency.into_key()); message.nonce = Some(idempotency.into_key());
// Send the message // Send the message
message.send(db, author, &channel, generate_embeds).await?; message
.send(db, amqp, author, user, member, &channel, generate_embeds)
.await?;
Ok(message) Ok(message)
} }
@@ -369,13 +446,18 @@ impl Message {
pub async fn send_without_notifications( pub async fn send_without_notifications(
&mut self, &mut self,
db: &Database, db: &Database,
user: Option<v0::User>,
member: Option<v0::Member>,
is_dm: bool, is_dm: bool,
generate_embeds: bool, generate_embeds: bool,
// This determines if this function should queue the mentions task or if somewhere else will.
// If this is true, you MUST call tasks::ack::queue yourself.
mentions_elsewhere: bool,
) -> Result<()> { ) -> Result<()> {
db.insert_message(self).await?; db.insert_message(self).await?;
// Fan out events // Fan out events
EventV1::Message(self.clone().into()) EventV1::Message(self.clone().into_model(user, member))
.p(self.channel.to_string()) .p(self.channel.to_string())
.await; .await;
@@ -383,13 +465,12 @@ impl Message {
tasks::last_message_id::queue(self.channel.to_string(), self.id.to_string(), is_dm).await; tasks::last_message_id::queue(self.channel.to_string(), self.id.to_string(), is_dm).await;
// Add mentions for affected users // Add mentions for affected users
if let Some(mentions) = &self.mentions { if !mentions_elsewhere {
for user in mentions { if let Some(mentions) = &self.mentions {
tasks::ack::queue( tasks::ack::queue_message(
self.channel.to_string(), self.channel.to_string(),
user.to_string(), AckEvent::ProcessMessage {
AckEvent::AddMention { messages: vec![(None, self.clone(), mentions.clone(), true)],
ids: vec![self.id.to_string()],
}, },
) )
.await; .await;
@@ -412,33 +493,56 @@ impl Message {
} }
/// Send a message /// Send a message
#[allow(clippy::too_many_arguments)]
pub async fn send( pub async fn send(
&mut self, &mut self,
db: &Database, db: &Database,
amqp: Option<&AMQP>, // this is optional mostly for tests.
author: MessageAuthor<'_>, author: MessageAuthor<'_>,
user: Option<v0::User>,
member: Option<v0::Member>,
channel: &Channel, channel: &Channel,
generate_embeds: bool, generate_embeds: bool,
) -> Result<()> { ) -> Result<()> {
self.send_without_notifications( self.send_without_notifications(
db, db,
user.clone(),
member.clone(),
matches!(channel, Channel::DirectMessage { .. }), matches!(channel, Channel::DirectMessage { .. }),
generate_embeds, generate_embeds,
true,
) )
.await?; .await?;
// Push out Web Push notifications if !self.has_suppressed_notifications() {
crate::tasks::web_push::queue( // send Push notifications
{ tasks::ack::queue_message(
match channel { self.channel.to_string(),
Channel::DirectMessage { recipients, .. } AckEvent::ProcessMessage {
| Channel::Group { recipients, .. } => recipients.clone(), messages: vec![(
Channel::TextChannel { .. } => self.mentions.clone().unwrap_or_default(), Some(
_ => vec![], PushNotification::from(
} self.clone().into_model(user, member),
}, Some(author),
PushNotification::from(self.clone().into(), Some(author), &channel.id()).await, channel.to_owned().into(),
) )
.await; .await,
),
self.clone(),
match channel {
Channel::DirectMessage { recipients, .. }
| Channel::Group { recipients, .. } => recipients.clone(),
Channel::TextChannel { .. } => {
self.mentions.clone().unwrap_or_default()
}
_ => vec![],
},
self.has_suppressed_notifications(),
)],
},
)
.await;
}
Ok(()) Ok(())
} }
@@ -452,10 +556,7 @@ impl Message {
})?; })?;
let media = if let Some(id) = embed.media { let media = if let Some(id) = embed.media {
Some( Some(File::use_attachment(db, &id, &self.id, &self.author).await?)
db.find_and_use_attachment(&id, "attachments", "message", &self.id)
.await?,
)
} else { } else {
None None
}; };
@@ -470,15 +571,37 @@ impl Message {
})) }))
} }
/// Whether this message has suppressed notifications
pub fn has_suppressed_notifications(&self) -> bool {
if let Some(flags) = self.flags {
flags & MessageFlags::SuppressNotifications as i32
== MessageFlags::SuppressNotifications as i32
} else {
false
}
}
/// Update message data /// Update message data
pub async fn update(&mut self, db: &Database, partial: PartialMessage) -> Result<()> { pub async fn update(
&mut self,
db: &Database,
partial: PartialMessage,
remove: Vec<FieldsMessage>,
) -> Result<()> {
self.apply_options(partial.clone()); self.apply_options(partial.clone());
db.update_message(&self.id, &partial).await?;
for field in &remove {
self.remove_field(field);
}
db.update_message(&self.id, &partial, remove.clone())
.await?;
EventV1::MessageUpdate { EventV1::MessageUpdate {
id: self.id.clone(), id: self.id.clone(),
channel: self.channel.clone(), channel: self.channel.clone(),
data: partial.into(), data: partial.into(),
clear: remove.into_iter().map(|field| field.into()).collect(),
} }
.p(self.channel.clone()) .p(self.channel.clone())
.await; .await;
@@ -498,13 +621,47 @@ impl Message {
.fetch_messages(query) .fetch_messages(query)
.await? .await?
.into_iter() .into_iter()
.map(Into::into) .map(|msg| msg.into_model(None, None))
.collect(); .collect();
if let Some(true) = include_users { if let Some(true) = include_users {
let user_ids = messages let user_ids = messages
.iter() .iter()
.map(|m| m.author.clone()) .flat_map(|m| {
let mut users = vec![m.author.clone()];
if let Some(system) = &m.system {
match system {
v0::SystemMessage::ChannelDescriptionChanged { by } => {
users.push(by.clone())
}
v0::SystemMessage::ChannelIconChanged { by } => users.push(by.clone()),
v0::SystemMessage::ChannelOwnershipChanged { from, to, .. } => {
users.push(from.clone());
users.push(to.clone())
}
v0::SystemMessage::ChannelRenamed { by, .. } => users.push(by.clone()),
v0::SystemMessage::UserAdded { by, id, .. }
| v0::SystemMessage::UserRemove { by, id, .. } => {
users.push(by.clone());
users.push(id.clone());
}
v0::SystemMessage::UserBanned { id, .. }
| v0::SystemMessage::UserKicked { id, .. }
| v0::SystemMessage::UserJoined { id, .. }
| v0::SystemMessage::UserLeft { id, .. } => {
users.push(id.clone());
}
v0::SystemMessage::Text { .. } => {}
v0::SystemMessage::MessagePinned { by, .. } => {
users.push(by.clone());
}
v0::SystemMessage::MessageUnpinned { by, .. } => {
users.push(by.clone());
}
}
}
users
})
.collect::<HashSet<String>>() .collect::<HashSet<String>>()
.into_iter() .into_iter()
.collect::<Vec<String>>(); .collect::<Vec<String>>();
@@ -558,7 +715,7 @@ impl Message {
) -> Result<()> { ) -> Result<()> {
let media: Option<v0::File> = if let Some(id) = embed.media { let media: Option<v0::File> = if let Some(id) = embed.media {
Some( Some(
db.find_and_use_attachment(&id, "attachments", "message", &self.id) File::use_attachment(db, &id, &self.id, &self.author)
.await? .await?
.into(), .into(),
) )
@@ -588,7 +745,7 @@ impl Message {
pub async fn add_reaction(&self, db: &Database, user: &User, emoji: &str) -> Result<()> { pub async fn add_reaction(&self, db: &Database, user: &User, emoji: &str) -> Result<()> {
// Check how many reactions are already on the message // Check how many reactions are already on the message
let config = config().await; let config = config().await;
if self.reactions.len() >= config.features.limits.default.message_reactions if self.reactions.len() >= config.features.limits.global.message_reactions
&& !self.reactions.contains_key(emoji) && !self.reactions.contains_key(emoji)
{ {
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation));
@@ -730,6 +887,12 @@ impl Message {
// Write to database // Write to database
db.clear_reaction(&self.id, emoji).await db.clear_reaction(&self.id, emoji).await
} }
pub fn remove_field(&mut self, field: &FieldsMessage) {
match field {
FieldsMessage::Pinned => self.pinned = None,
}
}
} }
impl SystemMessage { impl SystemMessage {
@@ -753,7 +916,7 @@ impl Interactions {
if let Some(reactions) = &self.reactions { if let Some(reactions) = &self.reactions {
permissions.throw_if_lacking_channel_permission(ChannelPermission::React)?; permissions.throw_if_lacking_channel_permission(ChannelPermission::React)?;
if reactions.len() > config.features.limits.default.message_reactions { if reactions.len() > config.features.limits.global.message_reactions {
return Err(create_error!(InvalidOperation)); return Err(create_error!(InvalidOperation));
} }

View File

@@ -1,6 +1,6 @@
use revolt_result::Result; use revolt_result::Result;
use crate::{AppendMessage, Message, MessageQuery, PartialMessage}; use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage};
mod mongodb; mod mongodb;
mod reference; mod reference;
@@ -20,7 +20,7 @@ pub trait AbstractMessages: Sync + Send {
async fn fetch_messages_by_id(&self, ids: &[String]) -> Result<Vec<Message>>; async fn fetch_messages_by_id(&self, ids: &[String]) -> Result<Vec<Message>>;
/// Update a given message with new information /// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()>; async fn update_message(&self, id: &str, message: &PartialMessage, remove: Vec<FieldsMessage>) -> Result<()>;
/// Append information to a given message /// Append information to a given message
async fn append_message(&self, id: &str, append: &AppendMessage) -> Result<()>; async fn append_message(&self, id: &str, append: &AppendMessage) -> Result<()>;

View File

@@ -5,7 +5,8 @@ use revolt_models::v0::MessageSort;
use revolt_result::Result; use revolt_result::Result;
use crate::{ use crate::{
AppendMessage, DocumentId, Message, MessageQuery, MessageTimePeriod, MongoDb, PartialMessage, AppendMessage, DocumentId, FieldsMessage, IntoDocumentPath, Message, MessageQuery,
MessageTimePeriod, MongoDb, PartialMessage,
}; };
use super::AbstractMessages; use super::AbstractMessages;
@@ -50,6 +51,10 @@ impl AbstractMessages for MongoDb {
false false
}; };
if let Some(pinned) = query.filter.pinned {
filter.insert("pinned", pinned);
};
// 2. Find query limit // 2. Find query limit
let limit = query.limit.unwrap_or(50); let limit = query.limit.unwrap_or(50);
@@ -166,7 +171,7 @@ impl AbstractMessages for MongoDb {
self.find_with_options( self.find_with_options(
COL, COL,
doc! { doc! {
"ids": { "_id": {
"$in": ids "$in": ids
} }
}, },
@@ -177,8 +182,22 @@ impl AbstractMessages for MongoDb {
} }
/// Update a given message with new information /// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()> { async fn update_message(
query!(self, update_one_by_id, COL, id, message, vec![], None).map(|_| ()) &self,
id: &str,
message: &PartialMessage,
remove: Vec<FieldsMessage>,
) -> Result<()> {
query!(
self,
update_one_by_id,
COL,
id,
message,
remove.iter().map(|x| x as &dyn IntoDocumentPath).collect(),
None
)
.map(|_| ())
} }
/// Append information to a given message /// Append information to a given message
@@ -296,6 +315,14 @@ impl AbstractMessages for MongoDb {
} }
} }
impl IntoDocumentPath for FieldsMessage {
fn as_path(&self) -> Option<&'static str> {
Some(match self {
FieldsMessage::Pinned => "pinned",
})
}
}
impl MongoDb { impl MongoDb {
pub async fn delete_bulk_messages(&self, projection: Document) -> Result<()> { pub async fn delete_bulk_messages(&self, projection: Document) -> Result<()> {
let mut for_attachments = projection.clone(); let mut for_attachments = projection.clone();

View File

@@ -2,7 +2,7 @@ use futures::future::try_join_all;
use indexmap::IndexSet; use indexmap::IndexSet;
use revolt_result::Result; use revolt_result::Result;
use crate::{AppendMessage, Message, MessageQuery, PartialMessage, ReferenceDb}; use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb};
use super::AbstractMessages; use super::AbstractMessages;
@@ -56,6 +56,12 @@ impl AbstractMessages for ReferenceDb {
} }
} }
if let Some(pinned) = query.filter.pinned {
if message.pinned.unwrap_or_default() == pinned {
return false
}
}
true true
}) })
.cloned() .cloned()
@@ -183,10 +189,15 @@ impl AbstractMessages for ReferenceDb {
} }
/// Update a given message with new information /// Update a given message with new information
async fn update_message(&self, id: &str, message: &PartialMessage) -> Result<()> { async fn update_message(&self, id: &str, message: &PartialMessage, remove: Vec<FieldsMessage>) -> Result<()> {
let mut messages = self.messages.lock().await; let mut messages = self.messages.lock().await;
if let Some(message_data) = messages.get_mut(id) { if let Some(message_data) = messages.get_mut(id) {
message_data.apply_options(message.to_owned()); message_data.apply_options(message.to_owned());
for field in remove {
#[allow(clippy::disallowed_methods)]
message_data.remove_field(&field);
}
Ok(()) Ok(())
} else { } else {
Err(create_error!(NotFound)) Err(create_error!(NotFound))

View File

@@ -5,6 +5,7 @@ mod channel_unreads;
mod channel_webhooks; mod channel_webhooks;
mod channels; mod channels;
mod emojis; mod emojis;
mod file_hashes;
mod files; mod files;
mod messages; mod messages;
mod ratelimit_events; mod ratelimit_events;
@@ -23,6 +24,7 @@ pub use channel_unreads::*;
pub use channel_webhooks::*; pub use channel_webhooks::*;
pub use channels::*; pub use channels::*;
pub use emojis::*; pub use emojis::*;
pub use file_hashes::*;
pub use files::*; pub use files::*;
pub use messages::*; pub use messages::*;
pub use ratelimit_events::*; pub use ratelimit_events::*;
@@ -46,6 +48,7 @@ pub trait AbstractDatabase:
+ channel_unreads::AbstractChannelUnreads + channel_unreads::AbstractChannelUnreads
+ channel_webhooks::AbstractWebhooks + channel_webhooks::AbstractWebhooks
+ emojis::AbstractEmojis + emojis::AbstractEmojis
+ file_hashes::AbstractAttachmentHashes
+ files::AbstractAttachments + files::AbstractAttachments
+ messages::AbstractMessages + messages::AbstractMessages
+ ratelimit_events::AbstractRatelimitEvents + ratelimit_events::AbstractRatelimitEvents

View File

@@ -92,7 +92,7 @@ impl Member {
server: &Server, server: &Server,
user: &User, user: &User,
channels: Option<Vec<Channel>>, channels: Option<Vec<Channel>>,
) -> Result<Vec<Channel>> { ) -> Result<(Member, Vec<Channel>)> {
if db.fetch_ban(&server.id, &user.id).await.is_ok() { if db.fetch_ban(&server.id, &user.id).await.is_ok() {
return Err(create_error!(Banned)); return Err(create_error!(Banned));
} }
@@ -161,12 +161,12 @@ impl Member {
id: user.id.clone(), id: user.id.clone(),
} }
.into_message(id.to_string()) .into_message(id.to_string())
.send_without_notifications(db, false, false) .send_without_notifications(db, None, None, false, false, false)
.await .await
.ok(); .ok();
} }
Ok(channels) Ok((member, channels))
} }
/// Update member data /// Update member data
@@ -202,7 +202,7 @@ impl Member {
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 = None, FieldsMember::CanReceive => self.can_receive = None,
FieldsMember::CanPublish => self.can_publish = None FieldsMember::CanPublish => self.can_publish = None,
} }
} }
@@ -242,6 +242,7 @@ impl Member {
EventV1::ServerMemberLeave { EventV1::ServerMemberLeave {
id: self.id.server.to_string(), id: self.id.server.to_string(),
user: self.id.user.to_string(), user: self.id.user.to_string(),
reason: intention.clone().into(),
} }
.p(self.id.server.to_string()) .p(self.id.server.to_string())
.await; .await;
@@ -263,7 +264,7 @@ impl Member {
} }
.into_message(id.to_string()) .into_message(id.to_string())
// TODO: support notifications here in the future? // TODO: support notifications here in the future?
.send_without_notifications(db, false, false) .send_without_notifications(db, None, None, false, false, false)
.await .await
.ok(); .ok();
} }

View File

@@ -173,8 +173,8 @@ impl IntoDocumentPath for FieldsMember {
FieldsMember::Nickname => "nickname", FieldsMember::Nickname => "nickname",
FieldsMember::Roles => "roles", FieldsMember::Roles => "roles",
FieldsMember::Timeout => "timeout", FieldsMember::Timeout => "timeout",
FieldsMember::CanPublish => "can_publish", FieldsMember::CanPublish => "is_publishing",
FieldsMember::CanReceive => "can_receive" FieldsMember::CanReceive => "is_receiving",
}) })
} }
} }

View File

@@ -53,17 +53,17 @@ impl AbstractServerMembers for ReferenceDb {
/// Fetch multiple members by their ids /// Fetch multiple members by their ids
async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>> { async fn fetch_members<'a>(&self, server_id: &str, ids: &'a [String]) -> Result<Vec<Member>> {
let server_members = self.server_members.lock().await; let server_members = self.server_members.lock().await;
ids.iter() Ok(ids
.map(|id| { .iter()
.filter_map(|id| {
server_members server_members
.get(&MemberCompositeKey { .get(&MemberCompositeKey {
server: server_id.to_string(), server: server_id.to_string(),
user: id.to_string(), user: id.to_string(),
}) })
.cloned() .cloned()
.ok_or_else(|| create_error!(NotFound))
}) })
.collect() .collect())
} }
/// Fetch member count of a server /// Fetch member count of a server

View File

@@ -175,7 +175,7 @@ impl Server {
vec![] vec![]
}; };
server.channels = channels.iter().map(|c| c.id()).collect(); server.channels = channels.iter().map(|c| c.id().to_string()).collect();
db.insert_server(&server).await?; db.insert_server(&server).await?;
Ok((server, channels)) Ok((server, channels))
} }

View File

@@ -214,14 +214,21 @@ impl MongoDb {
// Delete all emoji. // Delete all emoji.
self.col::<Document>("emojis") self.col::<Document>("emojis")
.delete_many( .update_many(
doc! { doc! {
"parent.id": &server_id "parent.id": &server_id
}, },
doc! {
"$set": {
"parent": {
"type": "Detached"
}
}
},
None, None,
) )
.await .await
.map_err(|_| create_database_error!("delete_many", "emojis"))?; .map_err(|_| create_database_error!("update_many", "emojis"))?;
// Delete all channels. // Delete all channels.
self.col::<Document>("channels") self.col::<Document>("channels")
@@ -253,7 +260,7 @@ impl MongoDb {
// Update many attachments with parent id. // Update many attachments with parent id.
self.delete_many_attachments(doc! { self.delete_many_attachments(doc! {
"object_id": &server_id "used_for.id": &server_id
}) })
.await?; .await?;

View File

@@ -0,0 +1,24 @@
use axum::{extract::FromRequestParts, http::request::Parts};
use revolt_result::{create_error, Error, Result};
use crate::{Database, User};
#[async_trait::async_trait]
impl FromRequestParts<Database> for User {
type Rejection = Error;
async fn from_request_parts(parts: &mut Parts, db: &Database) -> Result<User> {
if let Some(Ok(bot_token)) = parts.headers.get("x-bot-token").map(|v| v.to_str()) {
let bot = db.fetch_bot_by_token(bot_token).await?;
db.fetch_user(&bot.id).await
} else if let Some(Ok(session_token)) =
parts.headers.get("x-session-token").map(|v| v.to_str())
{
let session = db.fetch_session_by_token(session_token).await?;
db.fetch_user(&session.user_id).await
} else {
Err(create_error!(NotAuthenticated))
}
}
}

View File

@@ -1,3 +1,5 @@
#[cfg(feature = "axum-impl")]
mod axum;
mod model; mod model;
mod ops; mod ops;
#[cfg(feature = "rocket-impl")] #[cfg(feature = "rocket-impl")]
@@ -5,6 +7,8 @@ mod rocket;
#[cfg(feature = "rocket-impl")] #[cfg(feature = "rocket-impl")]
mod schema; mod schema;
#[cfg(feature = "axum-impl")]
pub use self::axum::*;
#[cfg(feature = "rocket-impl")] #[cfg(feature = "rocket-impl")]
pub use self::rocket::*; pub use self::rocket::*;
#[cfg(feature = "rocket-impl")] #[cfg(feature = "rocket-impl")]

View File

@@ -1,14 +1,17 @@
use std::{collections::HashSet, time::Duration}; use std::{collections::HashSet, str::FromStr, time::Duration};
use crate::{events::client::EventV1, Database, File, RatelimitEvent}; use crate::{events::client::EventV1, Database, File, RatelimitEvent, AMQP};
use authifier::config::{EmailVerificationConfig, Template};
use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use rand::seq::SliceRandom; use rand::seq::SliceRandom;
use redis_kiss::{get_connection, AsyncCommands}; use redis_kiss::{get_connection, AsyncCommands};
use revolt_config::config; use revolt_config::{config, FeaturesLimits};
use revolt_models::v0; use revolt_models::v0::{self, UserFlags};
use revolt_presence::filter_online; use revolt_presence::filter_online;
use revolt_result::{create_error, Result}; use revolt_result::{create_error, Result};
use serde_json::json;
use ulid::Ulid; use ulid::Ulid;
auto_derived_partial!( auto_derived_partial!(
@@ -50,6 +53,10 @@ auto_derived_partial!(
/// Bot information /// Bot information
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub bot: Option<BotInformation>, pub bot: Option<BotInformation>,
/// Time until user is unsuspended
#[serde(skip_serializing_if = "Option::is_none")]
pub suspended_until: Option<Timestamp>,
}, },
"PartialUser" "PartialUser"
); );
@@ -62,6 +69,11 @@ auto_derived!(
StatusPresence, StatusPresence,
ProfileContent, ProfileContent,
ProfileBackground, ProfileBackground,
DisplayName,
// internal fields
Suspension,
None,
} }
/// User's relationship with another user (or themselves) /// User's relationship with another user (or themselves)
@@ -165,6 +177,7 @@ impl Default for User {
flags: Default::default(), flags: Default::default(),
privileged: Default::default(), privileged: Default::default(),
bot: Default::default(), bot: Default::default(),
suspended_until: Default::default(),
} }
} }
} }
@@ -198,6 +211,22 @@ impl User {
Ok(user) Ok(user)
} }
/// Get limits for this user
pub async fn limits(&self) -> FeaturesLimits {
let config = config().await;
if ulid::Ulid::from_str(&self.id)
.expect("`ulid`")
.datetime()
.elapsed()
.expect("time went backwards")
<= Duration::from_secs(3600u64 * config.features.limits.global.new_user_hours as u64)
{
config.features.limits.new_user
} else {
config.features.limits.default
}
}
/// Get the relationship with another user /// Get the relationship with another user
pub fn relationship_with(&self, user_b: &str) -> RelationshipStatus { pub fn relationship_with(&self, user_b: &str) -> RelationshipStatus {
if self.id == user_b { if self.id == user_b {
@@ -236,12 +265,11 @@ impl User {
/// Check if this user can acquire another server /// Check if this user can acquire another server
pub async fn can_acquire_server(&self, db: &Database) -> Result<()> { pub async fn can_acquire_server(&self, db: &Database) -> Result<()> {
let config = config().await; if db.fetch_server_count(&self.id).await? <= self.limits().await.servers {
if db.fetch_server_count(&self.id).await? <= config.features.limits.default.servers {
Ok(()) Ok(())
} else { } else {
Err(create_error!(TooManyServers { Err(create_error!(TooManyServers {
max: config.features.limits.default.servers max: self.limits().await.servers
})) }))
} }
} }
@@ -277,23 +305,27 @@ impl User {
Ok(username) Ok(username)
} }
/// Find a user from a given token and hint /// Find a user and session ID from a given token and hint
#[async_recursion] #[async_recursion]
pub async fn from_token(db: &Database, token: &str, hint: UserHint) -> Result<User> { pub async fn from_token(db: &Database, token: &str, hint: UserHint) -> Result<(User, String)> {
match hint { match hint {
UserHint::Bot => { UserHint::Bot => Ok((
db.fetch_user( db.fetch_user(
&db.fetch_bot_by_token(token) &db.fetch_bot_by_token(token)
.await .await
.map_err(|_| create_error!(InvalidSession))? .map_err(|_| create_error!(InvalidSession))?
.id, .id,
) )
.await .await?,
String::new(),
)),
UserHint::User => {
let session = db.fetch_session_by_token(token).await?;
Ok((db.fetch_user(&session.user_id).await?, session.id))
} }
UserHint::User => db.fetch_user_by_token(token).await,
UserHint::Any => { UserHint::Any => {
if let Ok(user) = User::from_token(db, token, UserHint::User).await { if let Ok(result) = User::from_token(db, token, UserHint::User).await {
Ok(user) Ok(result)
} else { } else {
User::from_token(db, token, UserHint::Bot).await User::from_token(db, token, UserHint::Bot).await
} }
@@ -466,7 +498,12 @@ impl User {
} }
/// Add another user as a friend /// Add another user as a friend
pub async fn add_friend(&mut self, db: &Database, target: &mut User) -> Result<()> { pub async fn add_friend(
&mut self,
db: &Database,
amqp: &AMQP,
target: &mut User,
) -> Result<()> {
match self.relationship_with(&target.id) { match self.relationship_with(&target.id) {
RelationshipStatus::User => Err(create_error!(NoEffect)), RelationshipStatus::User => Err(create_error!(NoEffect)),
RelationshipStatus::Friend => Err(create_error!(AlreadyFriends)), RelationshipStatus::Friend => Err(create_error!(AlreadyFriends)),
@@ -474,6 +511,9 @@ impl User {
RelationshipStatus::Blocked => Err(create_error!(Blocked)), RelationshipStatus::Blocked => Err(create_error!(Blocked)),
RelationshipStatus::BlockedOther => Err(create_error!(BlockedByOther)), RelationshipStatus::BlockedOther => Err(create_error!(BlockedByOther)),
RelationshipStatus::Incoming => { RelationshipStatus::Incoming => {
// Accept incoming friend request
_ = amqp.friend_request_accepted(self, target).await;
self.apply_relationship( self.apply_relationship(
db, db,
target, target,
@@ -483,6 +523,28 @@ impl User {
.await .await
} }
RelationshipStatus::None => { RelationshipStatus::None => {
// Get this user's current count of outgoing friend requests
let count = self
.relations
.as_ref()
.map(|relations| {
relations
.iter()
.filter(|r| matches!(r.status, RelationshipStatus::Outgoing))
.count()
})
.unwrap_or_default();
// If we're over the limit, don't allow creating more requests
if count >= self.limits().await.outgoing_friend_requests {
return Err(create_error!(TooManyPendingFriendRequests {
max: self.limits().await.outgoing_friend_requests
}));
}
_ = amqp.friend_request_received(target, self).await;
// Send the friend request
self.apply_relationship( self.apply_relationship(
db, db,
target, target,
@@ -631,9 +693,107 @@ impl User {
x.background = None; x.background = None;
} }
} }
FieldsUser::DisplayName => self.display_name = None,
FieldsUser::Suspension => self.suspended_until = None,
FieldsUser::None => {}
} }
} }
/// Suspend the user
///
/// - If a duration is specified, the user will be automatically unsuspended after the given time.
/// - If a reason is specified, an email will be sent.
pub async fn suspend(
&mut self,
db: &Database,
duration_days: Option<usize>,
reason: Option<Vec<String>>,
) -> Result<()> {
let authifier = db.clone().to_authifier().await;
let mut account = authifier
.database
.find_account(&self.id)
.await
.map_err(|_| create_error!(InternalError))?;
account
.disable(&authifier)
.await
.map_err(|_| create_error!(InternalError))?;
account
.delete_all_sessions(&authifier, None)
.await
.map_err(|_| create_error!(InternalError))?;
self.update(
db,
PartialUser {
flags: Some(UserFlags::SuspendedUntil as i32),
suspended_until: duration_days.and_then(|dur| {
Timestamp::now_utc().checked_add(iso8601_timestamp::Duration::days(dur as i64))
}),
..Default::default()
},
vec![],
)
.await?;
if let Some(reason) = reason {
if let EmailVerificationConfig::Enabled { smtp, .. } =
authifier.config.email_verification
{
smtp.send_email(
account.email.clone(),
// maybe move this to common area?
&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!({
"email": account.email,
"list": reason.join(", "),
"duration": duration_days,
"duration_display": if duration_days.is_some() {
"block"
} else {
"none"
}
}),
)
.map_err(|_| create_error!(InternalError))?;
}
}
Ok(())
}
/// Unsuspend the user
pub async fn unsuspend(&mut self, db: &Database) -> Result<()> {
self.update(
db,
PartialUser {
flags: Some(0),
suspended_until: None,
..Default::default()
},
vec![],
)
.await?;
unimplemented!()
}
/// Permanently ban the user
///
/// - If a reason is specified, an email will be sent.
pub async fn ban(&mut self, _db: &Database, _reason: Option<String>) -> Result<()> {
// Send ban email (if reason provided)
unimplemented!()
}
/// Mark as deleted /// Mark as deleted
pub async fn mark_deleted(&mut self, db: &Database) -> Result<()> { pub async fn mark_deleted(&mut self, db: &Database) -> Result<()> {
self.update( self.update(
@@ -649,6 +809,7 @@ impl User {
FieldsUser::StatusPresence, FieldsUser::StatusPresence,
FieldsUser::ProfileContent, FieldsUser::ProfileContent,
FieldsUser::ProfileBackground, FieldsUser::ProfileBackground,
FieldsUser::Suspension,
], ],
) )
.await .await

View File

@@ -1,3 +1,4 @@
use authifier::models::Session;
use revolt_result::Result; use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User}; use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -16,8 +17,8 @@ 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 user from the database by their session token /// Fetch a session from the database by token
async fn fetch_user_by_token(&self, token: &str) -> Result<User>; 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>>;
@@ -57,4 +58,7 @@ 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)
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()>;
} }

View File

@@ -46,10 +46,9 @@ impl AbstractUsers for MongoDb {
.ok_or_else(|| create_error!(NotFound)) .ok_or_else(|| create_error!(NotFound))
} }
/// Fetch a user from the database by their session token /// Fetch a session from the database by token
async fn fetch_user_by_token(&self, token: &str) -> Result<User> { async fn fetch_session_by_token(&self, token: &str) -> Result<Session> {
let session = self self.col::<Session>("sessions")
.col::<Session>("sessions")
.find_one( .find_one(
doc! { doc! {
"token": token "token": token
@@ -58,9 +57,7 @@ impl AbstractUsers for MongoDb {
) )
.await .await
.map_err(|_| create_database_error!("find_one", "sessions"))? .map_err(|_| create_database_error!("find_one", "sessions"))?
.ok_or_else(|| create_error!(InvalidSession))?; .ok_or_else(|| create_error!(InvalidSession))
self.fetch_user(&session.user_id).await
} }
/// Fetch multiple users by their ids /// Fetch multiple users by their ids
@@ -319,6 +316,25 @@ impl AbstractUsers for MongoDb {
async fn delete_user(&self, id: &str) -> Result<()> { async fn delete_user(&self, id: &str) -> Result<()> {
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)
async fn remove_push_subscription_by_session_id(&self, session_id: &str) -> Result<()> {
self.col::<User>("sessions")
.update_one(
doc! {
"_id": session_id
},
doc! {
"$unset": {
"subscription": 1
}
},
None,
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
} }
impl IntoDocumentPath for FieldsUser { impl IntoDocumentPath for FieldsUser {
@@ -329,6 +345,9 @@ impl IntoDocumentPath for FieldsUser {
FieldsUser::ProfileContent => "profile.content", FieldsUser::ProfileContent => "profile.content",
FieldsUser::StatusPresence => "status.presence", FieldsUser::StatusPresence => "status.presence",
FieldsUser::StatusText => "status.text", FieldsUser::StatusText => "status.text",
FieldsUser::DisplayName => "display_name",
FieldsUser::Suspension => "suspended_until",
FieldsUser::None => "none",
}) })
} }
} }

View File

@@ -1,3 +1,4 @@
use authifier::models::Session;
use revolt_result::Result; use revolt_result::Result;
use crate::{FieldsUser, PartialUser, RelationshipStatus, User}; use crate::{FieldsUser, PartialUser, RelationshipStatus, User};
@@ -40,8 +41,8 @@ impl AbstractUsers for ReferenceDb {
.ok_or_else(|| create_error!(NotFound)) .ok_or_else(|| create_error!(NotFound))
} }
/// Fetch a user from the database by their session token /// Fetch a session from the database by token
async fn fetch_user_by_token(&self, _token: &str) -> Result<User> { async fn fetch_session_by_token(&self, _token: &str) -> Result<Session> {
todo!() todo!()
} }
@@ -162,4 +163,9 @@ impl AbstractUsers for ReferenceDb {
Err(create_error!(NotFound)) Err(create_error!(NotFound))
} }
} }
/// Remove push subscription for a session by session id (TODO: remove)
async fn remove_push_subscription_by_session_id(&self, _session_id: &str) -> Result<()> {
todo!()
}
} }

View File

@@ -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::Failure((Status::Unauthorized, authifier::Error::InvalidSession)) Outcome::Error((Status::Unauthorized, authifier::Error::InvalidSession))
} }
} }
} }

View File

@@ -1,19 +1,27 @@
// Queue Type: Debounced // Queue Type: Debounced
use crate::Database; use crate::{Database, Message, AMQP};
use deadqueue::limited::Queue; use deadqueue::limited::Queue;
use once_cell::sync::Lazy; use once_cell::sync::Lazy;
use std::{collections::HashMap, time::Duration}; use revolt_models::v0::PushNotification;
use rocket::form::validate::Contains;
use std::{
collections::{HashMap, HashSet},
time::Duration,
};
use validator::HasLen;
use revolt_result::Result;
use super::DelayedTask; use super::DelayedTask;
/// Enumeration of possible events /// Enumeration of possible events
#[derive(Debug, Eq, PartialEq)] #[derive(Debug, Eq, PartialEq)]
pub enum AckEvent { pub enum AckEvent {
/// Add mentions for a user in a channel /// Add mentions for a channel
AddMention { ProcessMessage {
/// Message IDs /// push notification, message, recipients, push silenced
ids: Vec<String>, messages: Vec<(Option<PushNotification>, Message, Vec<String>, bool)>,
}, },
/// Acknowledge message in a channel for a user /// Acknowledge message in a channel for a user
@@ -28,7 +36,7 @@ struct Data {
/// Channel to ack /// Channel to ack
channel: String, channel: String,
/// User to ack for /// User to ack for
user: String, user: Option<String>,
/// Event /// Event
event: AckEvent, event: AckEvent,
} }
@@ -41,21 +49,130 @@ struct Task {
static Q: Lazy<Queue<Data>> = Lazy::new(|| Queue::new(10_000)); static Q: Lazy<Queue<Data>> = Lazy::new(|| Queue::new(10_000));
/// Queue a new task for a worker /// Queue a new task for a worker
pub async fn queue(channel: String, user: String, event: AckEvent) { pub async fn queue_ack(channel: String, user: String, event: AckEvent) {
Q.try_push(Data { Q.try_push(Data {
channel, channel,
user, user: Some(user),
event, event,
}) })
.ok(); .ok();
info!("Queue is using {} slots from {}.", Q.len(), Q.capacity()); info!(
"Queue is using {} slots from {}. Queued type: ACK",
Q.len(),
Q.capacity()
);
}
pub async fn queue_message(channel: String, event: AckEvent) {
Q.try_push(Data {
channel,
user: None,
event,
})
.ok();
info!(
"Queue is using {} slots from {}. Queued type: MENTION",
Q.len(),
Q.capacity()
);
}
pub async fn handle_ack_event(
event: &AckEvent,
db: &Database,
amqp: &AMQP,
user: &Option<String>,
channel: &str,
) -> Result<()> {
match &event {
#[allow(clippy::disallowed_methods)] // event is sent by higher level function
AckEvent::AckMessage { id } => {
let user = user.as_ref().unwrap();
let user: &str = user.as_str();
let unread = db.fetch_unread(user, channel).await?;
let updated = db.acknowledge_message(channel, user, id).await?;
if let (Some(before), Some(after)) = (unread, updated) {
let before_mentions = before.mentions.unwrap_or_default().len();
let after_mentions = after.mentions.unwrap_or_default().len();
let mentions_acked = before_mentions - after_mentions;
if mentions_acked > 0 {
if let Err(err) = amqp
.ack_message(user.to_string(), channel.to_string(), id.to_owned())
.await
{
revolt_config::capture_error(&err);
}
};
}
}
AckEvent::ProcessMessage { messages } => {
let mut users: HashSet<&String> = HashSet::new();
debug!(
"Processing {} messages from channel {}",
messages.len(),
messages[0].1.channel
);
// find all the users we'll be notifying
messages
.iter()
.for_each(|(_, _, recipents, _)| users.extend(recipents.iter()));
debug!("Found {} users to notify.", users.len());
for user in users {
let message_ids: Vec<String> = messages
.iter()
.filter(|(_, _, recipients, _)| recipients.contains(user))
.map(|(_, message, _, _)| message.id.clone())
.collect();
if !message_ids.is_empty() {
db.add_mention_to_unread(channel, user, &message_ids)
.await?;
}
debug!("Added {} mentions for user {}", message_ids.len(), &user);
}
for (push, _, recipients, silenced) in messages {
if *silenced || recipients.is_empty() || push.is_none() {
debug!(
"Rejecting push: silenced: {}, recipient count: {}, push exists: {:?}",
*silenced,
recipients.length(),
push
);
continue;
}
debug!(
"Sending push event to AMQP; message {} for {} users",
push.as_ref().unwrap().message.id,
recipients.len()
);
if let Err(err) = amqp
.message_sent(recipients.clone(), push.clone().unwrap())
.await
{
revolt_config::capture_error(&err);
}
}
}
};
Ok(())
} }
/// Start a new worker /// Start a new worker
pub async fn worker(db: Database) { pub async fn worker(db: Database, amqp: AMQP) {
let mut tasks = HashMap::<(String, String), DelayedTask<Task>>::new(); let mut tasks = HashMap::<(Option<String>, String, u8), DelayedTask<Task>>::new();
let mut keys = vec![]; let mut keys: Vec<(Option<String>, String, u8)> = vec![];
loop { loop {
// Find due tasks. // Find due tasks.
@@ -69,18 +186,13 @@ pub async fn worker(db: Database) {
for key in &keys { for key in &keys {
if let Some(task) = tasks.remove(key) { if let Some(task) = tasks.remove(key) {
let Task { event } = task.data; let Task { event } = task.data;
let (user, channel) = key; let (user, channel, _) = key;
if let Err(err) = match &event { if let Err(err) = handle_ack_event(&event, &db, &amqp, user, channel).await {
#[allow(clippy::disallowed_methods)] // event is sent by higher level function revolt_config::capture_error(&err);
AckEvent::AckMessage { id } => db.acknowledge_message(channel, user, id).await, error!("{err:?} for {event:?}. ({user:?}, {channel})");
AckEvent::AddMention { ids } => {
db.add_mention_to_unread(channel, user, ids).await
}
} {
error!("{err:?} for {event:?}. ({user}, {channel})");
} else { } else {
info!("User {user} ack in {channel} with {event:?}"); info!("User {user:?} ack in {channel} with {event:?}");
} }
} }
} }
@@ -95,20 +207,41 @@ pub async fn worker(db: Database) {
mut event, mut event,
}) = Q.try_pop() }) = Q.try_pop()
{ {
let key = (user, channel); let key: (Option<String>, String, u8) = (
user,
channel,
match &event {
AckEvent::AckMessage { .. } => 0,
AckEvent::ProcessMessage { .. } => 1,
},
);
if let Some(task) = tasks.get_mut(&key) { if let Some(task) = tasks.get_mut(&key) {
task.delay();
match &mut event { match &mut event {
AckEvent::AddMention { ids } => { AckEvent::ProcessMessage { messages: new_data } => {
if let AckEvent::AddMention { ids: existing } = &mut task.data.event { if let AckEvent::ProcessMessage { messages: existing } =
existing.append(ids); &mut task.data.event
{
// add the new message to the list of messages to be processed.
existing.append(new_data);
// put a cap on the amount of messages that can be queued, for particularly active channels
if (existing.length() as u16)
< revolt_config::config()
.await
.features
.advanced
.process_message_delay_limit
{
task.delay();
}
} else { } else {
task.data.event = event; panic!("Somehow got an ack message in the add mention arm");
} }
} }
AckEvent::AckMessage { .. } => { AckEvent::AckMessage { .. } => {
// replace the last acked message with the new acked message
task.data.event = event; task.data.event = event;
task.delay();
} }
} }
} else { } else {

View File

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

@@ -1,6 +1,6 @@
//! Semi-important background task management //! Semi-important background task management
use crate::Database; use crate::{Database, AMQP};
use async_std::task; use async_std::task;
use std::time::Instant; use std::time::Instant;
@@ -8,17 +8,18 @@ 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;
pub mod web_push;
/// Spawn background workers /// Spawn background workers
pub async fn start_workers(db: Database, authifier_db: authifier::Database) { 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())); task::spawn(ack::worker(db.clone(), amqp.clone()));
task::spawn(last_message_id::worker(db.clone())); task::spawn(last_message_id::worker(db.clone()));
task::spawn(process_embeds::worker(db.clone())); task::spawn(process_embeds::worker(db.clone()));
task::spawn(web_push::worker(authifier_db.clone()));
} }
} }

View File

@@ -56,7 +56,7 @@ pub async fn worker(db: Database) {
let embeds = generate( let embeds = generate(
task.content, task.content,
&config.hosts.january, &config.hosts.january,
config.features.limits.default.message_embeds, config.features.limits.global.message_embeds,
semaphore, semaphore,
) )
.await; .await;

View File

@@ -1,162 +0,0 @@
use std::collections::HashSet;
use authifier::Database;
use base64::{
engine::{self},
Engine as _,
};
use deadqueue::limited::Queue;
use once_cell::sync::Lazy;
use revolt_config::config;
use revolt_models::v0::PushNotification;
use revolt_presence::filter_online;
use serde_json::json;
use web_push::{
ContentEncoding, IsahcWebPushClient, SubscriptionInfo, SubscriptionKeys, VapidSignatureBuilder,
WebPushClient, WebPushMessageBuilder,
};
/// Task information
#[derive(Debug)]
struct PushTask {
/// User IDs of the targets that are to receive this notification
recipients: Vec<String>,
/// Push Notification
payload: PushNotification,
}
static Q: Lazy<Queue<PushTask>> = Lazy::new(|| Queue::new(10_000));
/// Queue a new task for a worker
pub async fn queue(recipients: Vec<String>, payload: PushNotification) {
if recipients.is_empty() {
return;
}
let online_ids = filter_online(&recipients).await;
let recipients = (&recipients.into_iter().collect::<HashSet<String>>() - &online_ids)
.into_iter()
.collect::<Vec<String>>();
Q.try_push(PushTask {
recipients,
payload,
})
.ok();
info!("Queue is using {} slots from {}.", Q.len(), Q.capacity());
}
/// Start a new worker
pub async fn worker(db: Database) {
let config = config().await;
let web_push_client = IsahcWebPushClient::new().unwrap();
let fcm_client = if config.api.fcm.api_key.is_empty() {
None
} else {
Some(fcm::Client::new())
};
let web_push_private_key = engine::general_purpose::URL_SAFE_NO_PAD
.decode(config.api.vapid.private_key)
.expect("valid `VAPID_PRIVATE_KEY`");
loop {
let task = Q.pop().await;
if let Ok(sessions) = db.find_sessions_with_subscription(&task.recipients).await {
for session in sessions {
if let Some(sub) = session.subscription {
if sub.endpoint == "fcm" {
// Use Firebase Cloud Messaging
if let Some(client) = &fcm_client {
let PushNotification {
author,
icon,
image: _,
body,
tag,
timestamp: _,
url: _,
} = &task.payload;
let mut notification = fcm::NotificationBuilder::new();
notification.title(author);
notification.icon(icon);
notification.body(body);
notification.tag(tag);
// TODO: expand support for fields
let notification = notification.finalize();
let mut message_builder =
fcm::MessageBuilder::new(&config.api.fcm.api_key, &sub.auth);
message_builder.notification(notification);
if let Err(err) = client.send(message_builder.finalize()).await {
error!("Failed to send FCM notification! {:?}", err);
} else {
info!("Sent FCM notification to {:?}.", session.id);
}
} else {
info!("No FCM token was specified!");
}
} else {
// Use Web Push Standard
let subscription = SubscriptionInfo {
endpoint: sub.endpoint,
keys: SubscriptionKeys {
auth: sub.auth,
p256dh: sub.p256dh,
},
};
match VapidSignatureBuilder::from_pem(
std::io::Cursor::new(&web_push_private_key),
&subscription,
) {
Ok(sig_builder) => match sig_builder.build() {
Ok(signature) => {
let mut builder = WebPushMessageBuilder::new(&subscription);
builder.set_vapid_signature(signature);
let payload = json!(task.payload).to_string();
builder
.set_payload(ContentEncoding::AesGcm, payload.as_bytes());
match builder.build() {
Ok(msg) => match web_push_client.send(msg).await {
Ok(_) => {
info!(
"Sent Web Push notification to {:?}.",
session.id
)
}
Err(err) => {
error!("Hit error sending Web Push! {:?}", err)
}
},
Err(err) => {
error!(
"Failed to build message for {}! {:?}",
session.user_id, err
)
}
}
}
Err(err) => error!(
"Failed to build signature for {}! {:?}",
session.user_id, err
),
},
Err(err) => error!(
"Failed to create signature builder for {}! {:?}",
session.user_id, err
),
}
}
}
}
}
}
}

View File

@@ -1,7 +1,7 @@
use revolt_models::v0::*; use revolt_models::v0::*;
use revolt_permissions::{calculate_user_permissions, UserPermission}; use revolt_permissions::{calculate_user_permissions, UserPermission};
use crate::{util::permissions::DatabasePermissionQuery, Database}; use crate::{util::permissions::DatabasePermissionQuery, Database, FileUsedFor};
impl crate::Bot { impl crate::Bot {
pub fn into_public_bot(self, user: crate::User) -> PublicBot { pub fn into_public_bot(self, user: crate::User) -> PublicBot {
@@ -108,6 +108,7 @@ impl From<crate::Webhook> for Webhook {
id: value.id, id: value.id,
name: value.name, name: value.name,
avatar: value.avatar.map(|file| file.into()), avatar: value.avatar.map(|file| file.into()),
creator_id: value.creator_id,
channel_id: value.channel_id, channel_id: value.channel_id,
token: value.token, token: value.token,
permissions: value.permissions, permissions: value.permissions,
@@ -121,6 +122,7 @@ impl From<crate::PartialWebhook> for PartialWebhook {
id: value.id, id: value.id,
name: value.name, name: value.name,
avatar: value.avatar.map(|file| file.into()), avatar: value.avatar.map(|file| file.into()),
creator_id: value.creator_id,
channel_id: value.channel_id, channel_id: value.channel_id,
token: value.token, token: value.token,
permissions: value.permissions, permissions: value.permissions,
@@ -423,6 +425,10 @@ impl From<File> for crate::File {
user_id: value.user_id, user_id: value.user_id,
server_id: value.server_id, server_id: value.server_id,
object_id: value.object_id, object_id: value.object_id,
hash: None,
uploaded_at: None,
uploader_id: None,
used_for: None,
} }
} }
} }
@@ -463,26 +469,30 @@ impl From<Metadata> for crate::Metadata {
} }
} }
impl From<crate::Message> for Message { impl crate::Message {
fn from(value: crate::Message) -> Self { pub fn into_model(self, user: Option<User>, member: Option<Member>) -> Message {
Message { Message {
id: value.id, id: self.id,
nonce: value.nonce, nonce: self.nonce,
channel: value.channel, channel: self.channel,
author: value.author, author: self.author,
webhook: value.webhook, user,
content: value.content, member,
system: value.system.map(|system| system.into()), webhook: self.webhook,
attachments: value content: self.content,
system: self.system.map(Into::into),
attachments: self
.attachments .attachments
.map(|v| v.into_iter().map(|f| f.into()).collect()), .map(|v| v.into_iter().map(|f| f.into()).collect()),
edited: value.edited, edited: self.edited,
embeds: value.embeds, embeds: self.embeds,
mentions: value.mentions, mentions: self.mentions,
replies: value.replies, replies: self.replies,
reactions: value.reactions, reactions: self.reactions,
interactions: value.interactions.into(), interactions: self.interactions.into(),
masquerade: value.masquerade.map(|masq| masq.into()), masquerade: self.masquerade.map(Into::into),
flags: self.flags.map(|flags| flags as u32).unwrap_or_default(),
pinned: self.pinned,
} }
} }
} }
@@ -494,9 +504,11 @@ impl From<crate::PartialMessage> for PartialMessage {
nonce: value.nonce, nonce: value.nonce,
channel: value.channel, channel: value.channel,
author: value.author, author: value.author,
user: None,
member: None,
webhook: value.webhook, webhook: value.webhook,
content: value.content, content: value.content,
system: value.system.map(|system| system.into()), system: value.system.map(Into::into),
attachments: value attachments: value
.attachments .attachments
.map(|v| v.into_iter().map(|f| f.into()).collect()), .map(|v| v.into_iter().map(|f| f.into()).collect()),
@@ -505,8 +517,10 @@ impl From<crate::PartialMessage> for PartialMessage {
mentions: value.mentions, mentions: value.mentions,
replies: value.replies, replies: value.replies,
reactions: value.reactions, reactions: value.reactions,
interactions: value.interactions.map(|interactions| interactions.into()), interactions: value.interactions.map(Into::into),
masquerade: value.masquerade.map(|masq| masq.into()), masquerade: value.masquerade.map(Into::into),
flags: value.flags.map(|flags| flags as u32),
pinned: value.pinned,
} }
} }
} }
@@ -529,6 +543,8 @@ impl From<crate::SystemMessage> for SystemMessage {
crate::SystemMessage::UserKicked { id } => Self::UserKicked { id }, crate::SystemMessage::UserKicked { id } => Self::UserKicked { id },
crate::SystemMessage::UserLeft { id } => Self::UserLeft { id }, crate::SystemMessage::UserLeft { id } => Self::UserLeft { id },
crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by }, crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by },
crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by },
crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by },
} }
} }
} }
@@ -1011,26 +1027,31 @@ impl crate::User {
vec![] vec![]
}, },
badges: self.badges.unwrap_or_default() as u32, badges: self.badges.unwrap_or_default() as u32,
online: can_see_profile
&& revolt_presence::is_online(&self.id).await
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: if can_see_profile { status: if can_see_profile {
self.status.map(|status| status.into()) self.status.map(|status| status.into())
} else { } else {
None None
}, },
profile: if can_see_profile {
self.profile.map(|profile| profile.into())
} else {
None
},
flags: self.flags.unwrap_or_default() as u32, flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged, privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()), bot: self.bot.map(|bot| bot.into()),
relationship, relationship,
online: can_see_profile && revolt_presence::is_online(&self.id).await,
id: self.id, id: self.id,
} }
} }
/// Convert user object into user model assuming mutual connection /// Convert user object into user model assuming mutual connection
///
/// Relations will never be included, i.e. when we process ourselves
pub fn into_known<'a, P>(self, perspective: P, is_online: bool) -> User pub fn into_known<'a, P>(self, perspective: P, is_online: bool) -> User
where where
P: Into<Option<&'a crate::User>>, P: Into<Option<&'a crate::User>>,
@@ -1066,40 +1087,57 @@ impl crate::User {
discriminator: self.discriminator, discriminator: self.discriminator,
display_name: self.display_name, display_name: self.display_name,
avatar: self.avatar.map(|file| file.into()), avatar: self.avatar.map(|file| file.into()),
relations: if let Some(crate::User { id, .. }) = perspective { relations: vec![],
if id == &self.id {
self.relations
.unwrap_or_default()
.into_iter()
.map(|relation| relation.into())
.collect()
} else {
vec![]
}
} else {
vec![]
},
badges: self.badges.unwrap_or_default() as u32, badges: self.badges.unwrap_or_default() as u32,
online: can_see_profile
&& is_online
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: if can_see_profile { status: if can_see_profile {
self.status.map(|status| status.into()) self.status.map(|status| status.into())
} else { } else {
None None
}, },
profile: if can_see_profile {
self.profile.map(|profile| profile.into())
} else {
None
},
flags: self.flags.unwrap_or_default() as u32, flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged, privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()), bot: self.bot.map(|bot| bot.into()),
relationship, relationship,
online: can_see_profile && is_online,
id: self.id, id: self.id,
} }
} }
pub async fn into_self(self) -> User { /// Convert user object into user model without presence information
pub fn into_known_static<'a>(self, is_online: bool) -> User {
User {
username: self.username,
discriminator: self.discriminator,
display_name: self.display_name,
avatar: self.avatar.map(|file| file.into()),
relations: vec![],
badges: self.badges.unwrap_or_default() as u32,
online: is_online
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: self.status.map(|status| status.into()),
flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()),
relationship: RelationshipStatus::None, // events client will populate this from cache
id: self.id,
}
}
pub async fn into_self(self, force_online: bool) -> User {
User { User {
username: self.username, username: self.username,
discriminator: self.discriminator, discriminator: self.discriminator,
@@ -1115,13 +1153,19 @@ impl crate::User {
}) })
.unwrap_or_default(), .unwrap_or_default(),
badges: self.badges.unwrap_or_default() as u32, badges: self.badges.unwrap_or_default() as u32,
online: (force_online || revolt_presence::is_online(&self.id).await)
&& !matches!(
self.status,
Some(crate::UserStatus {
presence: Some(crate::Presence::Invisible),
..
})
),
status: self.status.map(|status| status.into()), status: self.status.map(|status| status.into()),
profile: self.profile.map(|profile| profile.into()),
flags: self.flags.unwrap_or_default() as u32, flags: self.flags.unwrap_or_default() as u32,
privileged: self.privileged, privileged: self.privileged,
bot: self.bot.map(|bot| bot.into()), bot: self.bot.map(|bot| bot.into()),
relationship: RelationshipStatus::User, relationship: RelationshipStatus::User,
online: revolt_presence::is_online(&self.id).await,
id: self.id, id: self.id,
} }
} }
@@ -1145,10 +1189,11 @@ impl From<User> for crate::User {
relations: None, relations: None,
badges: Some(value.badges as i32), badges: Some(value.badges as i32),
status: value.status.map(Into::into), status: value.status.map(Into::into),
profile: value.profile.map(Into::into), profile: None,
flags: Some(value.flags as i32), flags: Some(value.flags as i32),
privileged: value.privileged, privileged: value.privileged,
bot: value.bot.map(Into::into), bot: value.bot.map(Into::into),
suspended_until: None,
} }
} }
} }
@@ -1168,7 +1213,6 @@ impl From<crate::PartialUser> for PartialUser {
}), }),
badges: value.badges.map(|badges| badges as u32), badges: value.badges.map(|badges| badges as u32),
status: value.status.map(|status| status.into()), status: value.status.map(|status| status.into()),
profile: value.profile.map(|profile| profile.into()),
flags: value.flags.map(|flags| flags as u32), flags: value.flags.map(|flags| flags as u32),
privileged: value.privileged, privileged: value.privileged,
bot: value.bot.map(|bot| bot.into()), bot: value.bot.map(|bot| bot.into()),
@@ -1187,6 +1231,9 @@ impl From<FieldsUser> for crate::FieldsUser {
FieldsUser::ProfileContent => crate::FieldsUser::ProfileContent, FieldsUser::ProfileContent => crate::FieldsUser::ProfileContent,
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::Internal => crate::FieldsUser::None,
} }
} }
} }
@@ -1199,6 +1246,10 @@ impl From<crate::FieldsUser> for FieldsUser {
crate::FieldsUser::ProfileContent => FieldsUser::ProfileContent, crate::FieldsUser::ProfileContent => FieldsUser::ProfileContent,
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::Suspension => FieldsUser::Internal,
crate::FieldsUser::None => FieldsUser::Internal,
} }
} }
} }
@@ -1301,3 +1352,18 @@ impl From<BotInformation> for crate::BotInformation {
} }
} }
} }
impl From<crate::FieldsMessage> for FieldsMessage {
fn from(value: crate::FieldsMessage) -> Self {
match value {
crate::FieldsMessage::Pinned => FieldsMessage::Pinned,
}
}
}
impl From<FieldsMessage> for crate::FieldsMessage {
fn from(value: FieldsMessage) -> Self {
match value {
FieldsMessage::Pinned => crate::FieldsMessage::Pinned,
}
}
}

View File

@@ -0,0 +1,337 @@
use std::{collections::HashMap, hash::RandomState};
use revolt_permissions::{
ChannelPermission, ChannelType, Override, OverrideField, PermissionValue, ALLOW_IN_TIMEOUT,
DEFAULT_PERMISSION_DIRECT_MESSAGE,
};
use crate::{Channel, Database, Member, Server, User};
#[derive(Clone)]
pub struct BulkDatabasePermissionQuery<'a> {
#[allow(dead_code)]
database: &'a Database,
server: Server,
channel: Option<Channel>,
users: Option<Vec<User>>,
members: Option<Vec<Member>>,
// In case the users or members are fetched as part of the permissions checking operation
pub(crate) cached_users: Option<Vec<User>>,
pub(crate) cached_members: Option<Vec<Member>>,
cached_member_perms: Option<HashMap<String, PermissionValue>>,
}
impl<'z, 'x> BulkDatabasePermissionQuery<'x> {
pub async fn members_can_see_channel(&'z mut self) -> HashMap<String, bool>
where
'z: 'x,
{
let member_perms = if self.cached_member_perms.is_some() {
// This isn't done as an if let to prevent borrow checker errors with the mut self call when the perms aren't cached.
let perms = self.cached_member_perms.as_ref().unwrap();
perms
.iter()
.map(|(m, p)| {
(
m.clone(),
p.has_channel_permission(ChannelPermission::ViewChannel),
)
})
.collect()
} else {
calculate_members_permissions(self)
.await
.iter()
.map(|(m, p)| {
(
m.clone(),
p.has_channel_permission(ChannelPermission::ViewChannel),
)
})
.collect()
};
member_perms
}
}
impl<'z> BulkDatabasePermissionQuery<'z> {
pub fn new(database: &Database, server: Server) -> BulkDatabasePermissionQuery<'_> {
BulkDatabasePermissionQuery {
database,
server,
channel: None,
users: None,
members: None,
cached_members: None,
cached_users: None,
cached_member_perms: None,
}
}
pub async fn from_server_id<'a>(
db: &'a Database,
server: &str,
) -> BulkDatabasePermissionQuery<'a> {
BulkDatabasePermissionQuery {
database: db,
server: db.fetch_server(server).await.unwrap(),
channel: None,
users: None,
members: None,
cached_members: None,
cached_users: None,
cached_member_perms: None,
}
}
pub fn channel(self, channel: &'z Channel) -> BulkDatabasePermissionQuery {
BulkDatabasePermissionQuery {
channel: Some(channel.clone()),
..self
}
}
pub fn members(self, members: &'z [Member]) -> BulkDatabasePermissionQuery {
BulkDatabasePermissionQuery {
members: Some(members.to_owned()),
..self
}
}
pub fn users(self, users: &'z [User]) -> BulkDatabasePermissionQuery {
BulkDatabasePermissionQuery {
users: Some(users.to_owned()),
..self
}
}
/// Get the default channel permissions
/// Group channel defaults should be mapped to an allow-only override
#[allow(dead_code)]
async fn get_default_channel_permissions(&mut self) -> Override {
if let Some(channel) = &self.channel {
match channel {
Channel::Group { permissions, .. } => Override {
allow: permissions.unwrap_or(*DEFAULT_PERMISSION_DIRECT_MESSAGE as i64) as u64,
deny: 0,
},
Channel::TextChannel {
default_permissions,
..
}
| Channel::VoiceChannel {
default_permissions,
..
} => default_permissions.unwrap_or_default().into(),
_ => Default::default(),
}
} else {
Default::default()
}
}
#[allow(dead_code)]
fn get_channel_type(&mut self) -> ChannelType {
if let Some(channel) = &self.channel {
match channel {
Channel::DirectMessage { .. } => ChannelType::DirectMessage,
Channel::Group { .. } => ChannelType::Group,
Channel::SavedMessages { .. } => ChannelType::SavedMessages,
Channel::TextChannel { .. } | Channel::VoiceChannel { .. } => {
ChannelType::ServerChannel
}
}
} else {
ChannelType::Unknown
}
}
/// Get the ordered role overrides (from lowest to highest) for this member in this channel
#[allow(dead_code)]
async fn get_channel_role_overrides(&mut self) -> &HashMap<String, OverrideField> {
if let Some(channel) = &self.channel {
match channel {
Channel::TextChannel {
role_permissions, ..
}
| Channel::VoiceChannel {
role_permissions, ..
} => role_permissions,
_ => panic!("Not supported for non-server channels"),
}
} else {
panic!("No channel added to query")
}
}
}
/// Calculate members permissions in a server channel.
async fn calculate_members_permissions<'a>(
query: &'a mut BulkDatabasePermissionQuery<'a>,
) -> HashMap<String, PermissionValue> {
let mut resp = HashMap::new();
let (_, channel_role_permissions, channel_default_permissions) = match query
.channel
.as_ref()
.expect("A channel must be assigned to calculate channel permissions")
.clone()
{
Channel::TextChannel {
id,
role_permissions,
default_permissions,
..
}
| Channel::VoiceChannel {
id,
role_permissions,
default_permissions,
..
} => (id, role_permissions, default_permissions),
_ => panic!("Calculation of member permissions must be done on a server channel"),
};
if query.users.is_none() {
let ids: Vec<String> = query
.members
.as_ref()
.expect("No users or members added to the query")
.iter()
.map(|m| m.id.user.clone())
.collect();
query.cached_users = Some(
query
.database
.fetch_users(&ids[..])
.await
.expect("Failed to get data from the db"),
);
query.users = Some(query.cached_users.as_ref().unwrap().to_vec())
}
let users = query.users.as_ref().unwrap();
if query.members.is_none() {
let ids: Vec<String> = query
.users
.as_ref()
.expect("No users or members added to the query")
.iter()
.map(|m| m.id.clone())
.collect();
query.cached_members = Some(
query
.database
.fetch_members(&query.server.id, &ids[..])
.await
.expect("Failed to get data from the db"),
);
query.members = Some(query.cached_members.as_ref().unwrap().to_vec())
}
let members: HashMap<&String, &Member, RandomState> = HashMap::from_iter(
query
.members
.as_ref()
.unwrap()
.iter()
.map(|m| (&m.id.user, m)),
);
for user in users {
let member = members.get(&user.id);
// User isn't a part of the server
if member.is_none() {
resp.insert(user.id.clone(), 0_u64.into());
continue;
}
let member = *member.unwrap();
if user.privileged {
resp.insert(
user.id.clone(),
PermissionValue::from(ChannelPermission::GrantAllSafe),
);
continue;
}
if user.id == query.server.owner {
resp.insert(
user.id.clone(),
PermissionValue::from(ChannelPermission::GrantAllSafe),
);
continue;
}
// Get the user's server permissions
let mut permission = calculate_server_permissions(&query.server, user, member);
if let Some(defaults) = channel_default_permissions {
permission.apply(defaults.into());
}
// Get the applicable role overrides
let mut roles = channel_role_permissions
.iter()
.filter(|(id, _)| member.roles.contains(id))
.filter_map(|(id, permission)| {
query.server.roles.get(id).map(|role| {
let v: Override = (*permission).into();
(role.rank, v)
})
})
.collect::<Vec<(i64, Override)>>();
roles.sort_by(|a, b| b.0.cmp(&a.0));
let overrides = roles.into_iter().map(|(_, v)| v);
for role_override in overrides {
permission.apply(role_override)
}
resp.insert(user.id.clone(), permission);
}
resp
}
/// Calculates a member's server permissions
fn calculate_server_permissions(server: &Server, user: &User, member: &Member) -> PermissionValue {
if user.privileged || server.owner == user.id {
return ChannelPermission::GrantAllSafe.into();
}
let mut permissions: PermissionValue = server.default_permissions.into();
let mut roles = server
.roles
.iter()
.filter(|(id, _)| member.roles.contains(id))
.map(|(_, role)| {
let v: Override = role.permissions.into();
(role.rank, v)
})
.collect::<Vec<(i64, Override)>>();
roles.sort_by(|a, b| b.0.cmp(&a.0));
let role_overrides: Vec<Override> = roles.into_iter().map(|(_, v)| v).collect();
for role in role_overrides {
permissions.apply(role);
}
if member.in_timeout() {
permissions.restrict(*ALLOW_IN_TIMEOUT);
}
permissions
}

View File

@@ -18,6 +18,10 @@ static TOKEN_CACHE: Lazy<Mutex<lru::LruCache<String, ()>>> =
Lazy::new(|| Mutex::new(lru::LruCache::new(NonZeroUsize::new(1000).unwrap()))); Lazy::new(|| Mutex::new(lru::LruCache::new(NonZeroUsize::new(1000).unwrap())));
impl IdempotencyKey { impl IdempotencyKey {
pub fn unchecked_from_string(key: String) -> Self {
Self { key }
}
// Backwards compatibility. // Backwards compatibility.
// Issue #109 // Issue #109
pub async fn consume_nonce(&mut self, v: Option<String>) -> Result<()> { pub async fn consume_nonce(&mut self, v: Option<String>) -> Result<()> {
@@ -98,7 +102,7 @@ impl<'r> FromRequest<'r> for IdempotencyKey {
.map(|k| k.to_string()) .map(|k| k.to_string())
{ {
if key.len() > 64 { if key.len() > 64 {
return Outcome::Failure(( return Outcome::Error((
Status::BadRequest, Status::BadRequest,
create_error!(FailedValidation { create_error!(FailedValidation {
error: "idempotency key too long".to_string(), error: "idempotency key too long".to_string(),
@@ -109,7 +113,7 @@ impl<'r> FromRequest<'r> for IdempotencyKey {
let idempotency = IdempotencyKey { key }; let idempotency = IdempotencyKey { key };
let mut cache = TOKEN_CACHE.lock().await; let mut cache = TOKEN_CACHE.lock().await;
if cache.get(&idempotency.key).is_some() { if cache.get(&idempotency.key).is_some() {
return Outcome::Failure((Status::Conflict, create_error!(DuplicateNonce))); return Outcome::Error((Status::Conflict, create_error!(DuplicateNonce)));
} }
cache.put(idempotency.key.clone(), ()); cache.put(idempotency.key.clone(), ());

View File

@@ -1,4 +1,5 @@
pub mod bridge; pub mod bridge;
pub mod bulk_permissions;
pub mod idempotency; pub mod idempotency;
pub mod permissions; pub mod permissions;
pub mod reference; pub mod reference;

View File

@@ -53,6 +53,12 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
if let Some(other_user) = &self.user { if let Some(other_user) = &self.user {
if self.perspective.id == other_user.id { if self.perspective.id == other_user.id {
return RelationshipStatus::User; return RelationshipStatus::User;
} else if let Some(bot) = &other_user.bot {
// For the purposes of permissions checks,
// assume owner is the same as bot
if self.perspective.id == bot.owner {
return RelationshipStatus::User;
}
} }
if let Some(relations) = &self.perspective.relations { if let Some(relations) = &self.perspective.relations {
@@ -181,7 +187,7 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
async fn do_we_have_publish_overwrites(&mut self) -> bool { async fn do_we_have_publish_overwrites(&mut self) -> bool {
if let Some(member) = &self.member { if let Some(member) = &self.member {
member.can_publish.unwrap_or(true) member.can_publish.unwrap_or(false)
} else { } else {
false false
} }
@@ -189,7 +195,7 @@ impl PermissionQuery for DatabasePermissionQuery<'_> {
async fn do_we_have_receive_overwrites(&mut self) -> bool { async fn do_we_have_receive_overwrites(&mut self) -> bool {
if let Some(member) = &self.member { if let Some(member) = &self.member {
member.can_receive.unwrap_or(true) member.can_receive.unwrap_or(false)
} else { } else {
false false
} }

View File

@@ -1,3 +1,5 @@
use std::str::FromStr;
use revolt_result::Result; use revolt_result::Result;
#[cfg(feature = "rocket-impl")] #[cfg(feature = "rocket-impl")]
use rocket::request::FromParam; use rocket::request::FromParam;
@@ -44,9 +46,27 @@ impl Reference {
db.fetch_channel(&self.id).await db.fetch_channel(&self.id).await
} }
/// Fetch invite from Ref /// Fetch invite from Ref or create invite to server if discoverable
pub async fn as_invite(&self, db: &Database) -> Result<Invite> { pub async fn as_invite(&self, db: &Database) -> Result<Invite> {
db.fetch_invite(&self.id).await if ulid::Ulid::from_str(&self.id).is_ok() {
let server = self.as_server(db).await?;
if !server.discoverable {
return Err(create_error!(NotFound));
}
Ok(Invite::Server {
code: self.id.to_string(),
server: server.id,
creator: server.owner,
channel: server
.channels
.into_iter()
.next()
.ok_or(create_error!(NotFound))?,
})
} else {
db.fetch_invite(&self.id).await
}
} }
/// Fetch message from Ref /// Fetch message from Ref

View File

@@ -0,0 +1,554 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.3.1 -->
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
body,
table,
td {
font-family: Helvetica, Arial, sans-serif !important;
}
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 150%;
}
a {
text-decoration: none;
}
* {
color: inherit;
}
a[x-apple-data-detectors],
u + #body a,
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
img {
-ms-interpolation-mode: bicubic;
}
table:not([class^="s-"]) {
font-family: Helvetica, Arial, sans-serif;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
border-spacing: 0px;
border-collapse: collapse;
}
table:not([class^="s-"]) td {
border-spacing: 0px;
border-collapse: collapse;
}
@media screen and (max-width: 600px) {
.w-full,
.w-full > tbody > tr > td {
width: 100% !important;
}
.w-24,
.w-24 > tbody > tr > td {
width: 96px !important;
}
.p-lg-10:not(table),
.p-lg-10:not(.btn) > tbody > tr > td,
.p-lg-10.btn td a {
padding: 0 !important;
}
.p-3:not(table),
.p-3:not(.btn) > tbody > tr > td,
.p-3.btn td a {
padding: 12px !important;
}
.p-6:not(table),
.p-6:not(.btn) > tbody > tr > td,
.p-6.btn td a {
padding: 24px !important;
}
*[class*="s-lg-"] > tbody > tr > td {
font-size: 0 !important;
line-height: 0 !important;
height: 0 !important;
}
.s-4 > tbody > tr > td {
font-size: 16px !important;
line-height: 16px !important;
height: 16px !important;
}
.s-6 > tbody > tr > td {
font-size: 24px !important;
line-height: 24px !important;
height: 24px !important;
}
.s-10 > tbody > tr > td {
font-size: 40px !important;
line-height: 40px !important;
height: 40px !important;
}
}
</style>
</head>
<body
class="bg-light"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<table
class="bg-light body"
valign="top"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<tbody>
<tr>
<td
valign="top"
style="line-height: 24px; font-size: 16px; margin: 0"
align="left"
bgcolor="#f7fafc"
>
<table
class="container"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
>
<tbody>
<tr>
<td
align="center"
style="
line-height: 24px;
font-size: 16px;
margin: 0;
padding: 0 16px;
"
>
<!--[if (gte mso 9)|(IE)]>
<table align="center" role="presentation">
<tbody>
<tr>
<td width="600">
<![endif]-->
<table
align="center"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px; margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="ax-center"
role="presentation"
align="center"
border="0"
cellpadding="0"
cellspacing="0"
style="margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<img
alt="Revolt Logo"
class="w-24"
src="https://app.revolt.chat/assets/logo_round.png"
style="
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
display: block;
width: 96px;
border-style: none;
border-width: 0;
"
width="96"
/>
</td>
</tr>
</tbody>
</table>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="card p-6 p-lg-10 space-y-4"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
width: 100%;
overflow: hidden;
border: 1px solid #e2e8f0;
"
bgcolor="#ffffff"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
padding: 40px;
"
align="left"
bgcolor="#ffffff"
>
<h1
class="h3 fw-700"
style="
padding-top: 0;
padding-bottom: 0;
font-weight: 700 !important;
vertical-align: baseline;
font-size: 28px;
line-height: 33.6px;
margin: 0;
"
align="left"
>
Account Deletion
</h1>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
class=""
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
You requested to have your account
deleted, if you did not perform this
action please take measures to secure your
account immediately.
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="btn btn-primary p-3 fw-700"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
font-weight: 700 !important;
"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
border-radius: 6px;
font-weight: 700 !important;
margin: 0;
"
align="center"
bgcolor="#0d6efd"
>
<a
href="{{url}}"
style="
color: #ffffff;
font-size: 16px;
font-family: Helvetica, Arial,
sans-serif;
text-decoration: none;
border-radius: 6px;
line-height: 20px;
display: block;
font-weight: 700 !important;
white-space: nowrap;
background-color: #0d6efd;
padding: 12px;
border: 1px solid #0d6efd;
"
>Confirm</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class="text-muted text-center"
style="color: #718096"
align="center"
>
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</tbody>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -23,7 +23,7 @@
<div class="text-muted text-center my-6"> <div class="text-muted text-center my-6">
This email is intended for {{email}}<br /> This email is intended for {{email}}<br />
Sent from Revolt<br /> Sent from Revolt<br />
Made in the EU Made in Europe
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,4 +4,4 @@ Please navigate to: {{url}}
This email is intended for {{email}} This email is intended for {{email}}
Sent by Revolt Sent by Revolt
Made in the EU Made in Europe

View File

@@ -0,0 +1,552 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
<!-- Compiled with Bootstrap Email version: 1.3.1 -->
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<meta name="x-apple-disable-message-reformatting" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta
name="format-detection"
content="telephone=no, date=no, address=no, email=no"
/>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<style type="text/css">
body,
table,
td {
font-family: Helvetica, Arial, sans-serif !important;
}
.ExternalClass {
width: 100%;
}
.ExternalClass,
.ExternalClass p,
.ExternalClass span,
.ExternalClass font,
.ExternalClass td,
.ExternalClass div {
line-height: 150%;
}
a {
text-decoration: none;
}
* {
color: inherit;
}
a[x-apple-data-detectors],
u + #body a,
#MessageViewBody a {
color: inherit;
text-decoration: none;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
line-height: inherit;
}
img {
-ms-interpolation-mode: bicubic;
}
table:not([class^="s-"]) {
font-family: Helvetica, Arial, sans-serif;
mso-table-lspace: 0pt;
mso-table-rspace: 0pt;
border-spacing: 0px;
border-collapse: collapse;
}
table:not([class^="s-"]) td {
border-spacing: 0px;
border-collapse: collapse;
}
@media screen and (max-width: 600px) {
.w-full,
.w-full > tbody > tr > td {
width: 100% !important;
}
.w-24,
.w-24 > tbody > tr > td {
width: 96px !important;
}
.p-lg-10:not(table),
.p-lg-10:not(.btn) > tbody > tr > td,
.p-lg-10.btn td a {
padding: 0 !important;
}
.p-3:not(table),
.p-3:not(.btn) > tbody > tr > td,
.p-3.btn td a {
padding: 12px !important;
}
.p-6:not(table),
.p-6:not(.btn) > tbody > tr > td,
.p-6.btn td a {
padding: 24px !important;
}
*[class*="s-lg-"] > tbody > tr > td {
font-size: 0 !important;
line-height: 0 !important;
height: 0 !important;
}
.s-4 > tbody > tr > td {
font-size: 16px !important;
line-height: 16px !important;
height: 16px !important;
}
.s-6 > tbody > tr > td {
font-size: 24px !important;
line-height: 24px !important;
height: 24px !important;
}
.s-10 > tbody > tr > td {
font-size: 40px !important;
line-height: 40px !important;
height: 40px !important;
}
}
</style>
</head>
<body
class="bg-light"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<table
class="bg-light body"
valign="top"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
outline: 0;
width: 100%;
min-width: 100%;
height: 100%;
-webkit-text-size-adjust: 100%;
-ms-text-size-adjust: 100%;
font-family: Helvetica, Arial, sans-serif;
line-height: 24px;
font-weight: normal;
font-size: 16px;
-moz-box-sizing: border-box;
-webkit-box-sizing: border-box;
box-sizing: border-box;
color: #000000;
margin: 0;
padding: 0;
border-width: 0;
"
bgcolor="#f7fafc"
>
<tbody>
<tr>
<td
valign="top"
style="line-height: 24px; font-size: 16px; margin: 0"
align="left"
bgcolor="#f7fafc"
>
<table
class="container"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
>
<tbody>
<tr>
<td
align="center"
style="
line-height: 24px;
font-size: 16px;
margin: 0;
padding: 0 16px;
"
>
<!--[if (gte mso 9)|(IE)]>
<table align="center" role="presentation">
<tbody>
<tr>
<td width="600">
<![endif]-->
<table
align="center"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%; max-width: 600px; margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="ax-center"
role="presentation"
align="center"
border="0"
cellpadding="0"
cellspacing="0"
style="margin: 0 auto"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
margin: 0;
"
align="left"
>
<img
alt="Revolt Logo"
class="w-24"
src="https://app.revolt.chat/assets/logo_round.png"
style="
height: auto;
line-height: 100%;
outline: none;
text-decoration: none;
display: block;
width: 96px;
border-style: none;
border-width: 0;
"
width="96"
/>
</td>
</tr>
</tbody>
</table>
<table
class="s-10 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 40px;
font-size: 40px;
width: 100%;
height: 40px;
margin: 0;
"
align="left"
width="100%"
height="40"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="card p-6 p-lg-10 space-y-4"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
width: 100%;
overflow: hidden;
border: 1px solid #e2e8f0;
"
bgcolor="#ffffff"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
padding: 40px;
"
align="left"
bgcolor="#ffffff"
>
<h1
class="h3 fw-700"
style="
padding-top: 0;
padding-bottom: 0;
font-weight: 700 !important;
vertical-align: baseline;
font-size: 28px;
line-height: 33.6px;
margin: 0;
"
align="left"
>
Password Reset
</h1>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<p
class=""
style="
line-height: 24px;
font-size: 16px;
width: 100%;
margin: 0;
"
align="left"
>
You requested a password reset, click
below to continue.
</p>
<table
class="s-4 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 16px;
font-size: 16px;
width: 100%;
height: 16px;
margin: 0;
"
align="left"
width="100%"
height="16"
>
&#160;
</td>
</tr>
</tbody>
</table>
<table
class="btn btn-primary p-3 fw-700"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="
border-radius: 6px;
border-collapse: separate !important;
font-weight: 700 !important;
"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 16px;
border-radius: 6px;
font-weight: 700 !important;
margin: 0;
"
align="center"
bgcolor="#0d6efd"
>
<a
href="{{url}}"
style="
color: #ffffff;
font-size: 16px;
font-family: Helvetica, Arial,
sans-serif;
text-decoration: none;
border-radius: 6px;
line-height: 20px;
display: block;
font-weight: 700 !important;
white-space: nowrap;
background-color: #0d6efd;
padding: 12px;
border: 1px solid #0d6efd;
"
>Reset</a
>
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
<div
class="text-muted text-center"
style="color: #718096"
align="center"
>
This email is intended for {{email}}<br />
Sent from Revolt<br />
Made in Europe
</div>
<table
class="s-6 w-full"
role="presentation"
border="0"
cellpadding="0"
cellspacing="0"
style="width: 100%"
width="100%"
>
<tbody>
<tr>
<td
style="
line-height: 24px;
font-size: 24px;
width: 100%;
height: 24px;
margin: 0;
"
align="left"
width="100%"
height="24"
>
&#160;
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
<!--[if (gte mso 9)|(IE)]>
</td>
</tr>
</tbody>
</table>
<![endif]-->
</td>
</tr>
</tbody>
</table>
</td>
</tr>
</tbody>
</table>
</body>
</html>

View File

@@ -19,7 +19,7 @@
<div class="text-muted text-center my-6"> <div class="text-muted text-center my-6">
This email is intended for {{email}}<br /> This email is intended for {{email}}<br />
Sent from Revolt<br /> Sent from Revolt<br />
Made in the EU Made in Europe
</div> </div>
</div> </div>
</body> </body>

View File

@@ -4,4 +4,4 @@ Please navigate to: {{url}}
This email is intended for {{email}} This email is intended for {{email}}
Sent by Revolt Sent by Revolt
Made in the EU Made in Europe

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