Compare commits

..

13 Commits

Author SHA1 Message Date
Zomatree
6043ba6004 Merge branch 'main' into feat/oauth2 2025-11-03 03:28:19 +00:00
Zomatree
67773d3e43 chore: sync branch with main 2025-08-18 02:14:36 +01:00
Zomatree
14ea180683 refactor: simplify scope struct macro 2025-08-18 01:58:15 +01:00
Zomatree
83c15404a5 fix: use correct key name for auth bots collection 2025-08-18 01:58:15 +01:00
Zomatree
9d4bcb5e3d chore: rework oauth2 route scoping 2025-08-18 01:58:15 +01:00
Zomatree
f895ca7b23 feat: allow connecting to ws via oauth2 2025-08-18 01:58:15 +01:00
Zomatree
ef65223c89 feat: initial work on refresh tokens 2025-08-18 01:58:14 +01:00
Zomatree
660e646b2b feat: initial oauth2 token revoking 2025-08-18 01:58:14 +01:00
Zomatree
5a5f84f207 fix: add more oauth2 checks 2025-08-18 01:58:14 +01:00
Zomatree
11eee02cfe feat: allowed scopes 2025-08-18 01:58:14 +01:00
Zomatree
1e5b27ff9e fix(oauth2): use public bot 2025-08-18 01:58:14 +01:00
Zomatree
3ae25fbcfe fix(oauth2): fix spec discrepancies 2025-08-18 01:58:14 +01:00
Zomatree
9e05e5be7e feat: initial oauth2 implementation 2025-08-18 01:58:14 +01:00
429 changed files with 9303 additions and 156191 deletions

View File

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

View File

@@ -1,24 +0,0 @@
<!-- Describe your changes -->
Fixes # (issue)
## How was this PR tested?
<!-- What did you do to test your changes? -->
- [ ] Test A
- [ ] Test B
## Checklist:
- [ ] I have carefully read [the contributing guidelines](https://developers.stoat.chat/developing/contrib/)
- [ ] I have performed a self-review of my own code
- [ ] I have made corresponding changes to the documentation if applicable
- [ ] I have no unrelated changes in the PR
- [ ] I have confirmed that any new dependencies are strictly necessary
- [ ] I have written tests for new code (if applicable)
- [ ] I have followed naming conventions/patterns in the surrounding code
## Please declare, if any, LLM usage involved in creating this PR
...

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,46 +0,0 @@
name: Docker PR Image Cleanup
on:
pull_request:
types:
- closed
permissions:
contents: read
packages: write
concurrency:
group: docker-cleanup-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
cleanup:
runs-on: ubuntu-latest
if: ${{ !github.event.pull_request.head.repo.fork }}
strategy:
fail-fast: false
matrix:
package:
- base
- api
- events
- file-server
- proxy
- gifbox
- crond
- pushd
- voice-ingress
steps:
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ORG: stoatchat
PACKAGE: ${{ matrix.package }}
TAG: pr-${{ github.event.pull_request.number }}
run: |
set -euo pipefail
gh api --paginate \
"/orgs/${ORG}/packages/container/${PACKAGE}/versions" \
--jq ".[] | select(.metadata.container.tags | index(\"${TAG}\")) | .id" \
| while read -r id; do
gh api -X DELETE "/orgs/${ORG}/packages/container/${PACKAGE}/versions/${id}"
done

View File

@@ -5,21 +5,20 @@ on:
tags:
- "*"
pull_request:
workflow_dispatch:
branches:
- "main"
paths:
- "Dockerfile"
permissions:
contents: read
packages: write
concurrency:
group: docker-test-${{ github.head_ref || github.ref }}
cancel-in-progress: true
jobs:
base:
name: Test base image build (fork)
runs-on: arc-runner-set
if: github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork
name: Test base image build
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
# Configure build environment
- name: Checkout
@@ -39,8 +38,8 @@ jobs:
cache-to: type=gha,scope=buildx-base-multi-arch,mode=max
publish:
runs-on: arc-runner-set
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.head.repo.fork }}
runs-on: self-hosted
if: github.event_name != 'pull_request'
name: Publish Docker images
steps:
# Configure build environment
@@ -57,15 +56,6 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Determine base image tag
id: base
run: |
if [ "${{ github.event_name }}" = "pull_request" ]; then
echo "tag=pr-${{ github.event.number }}" >> "$GITHUB_OUTPUT"
else
echo "tag=latest" >> "$GITHUB_OUTPUT"
fi
# Build the image
- name: Build base image
uses: docker/build-push-action@v4
@@ -73,9 +63,7 @@ jobs:
context: .
push: true
platforms: linux/amd64,linux/arm64
tags: ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
cache-from: type=gha,scope=buildx-base-multi-arch
cache-to: type=gha,scope=buildx-base-multi-arch,mode=max
tags: ghcr.io/${{ github.repository_owner }}/base:latest
# stoatchat/api
- name: Docker meta
@@ -93,7 +81,7 @@ jobs:
file: crates/delta/Dockerfile
tags: ${{ steps.meta-delta.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-delta.outputs.labels }}
# stoatchat/events
@@ -112,7 +100,7 @@ jobs:
file: crates/bonfire/Dockerfile
tags: ${{ steps.meta-bonfire.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-bonfire.outputs.labels }}
# stoatchat/file-server
@@ -131,7 +119,7 @@ jobs:
file: crates/services/autumn/Dockerfile
tags: ${{ steps.meta-autumn.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-autumn.outputs.labels }}
# stoatchat/proxy
@@ -150,7 +138,7 @@ jobs:
file: crates/services/january/Dockerfile
tags: ${{ steps.meta-january.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-january.outputs.labels }}
# stoatchat/gifbox
@@ -169,7 +157,7 @@ jobs:
file: crates/services/gifbox/Dockerfile
tags: ${{ steps.meta-gifbox.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-gifbox.outputs.labels }}
# stoatchat/crond
@@ -188,7 +176,7 @@ jobs:
file: crates/daemons/crond/Dockerfile
tags: ${{ steps.meta-crond.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-crond.outputs.labels }}
# stoatchat/pushd
@@ -207,24 +195,5 @@ jobs:
file: crates/daemons/pushd/Dockerfile
tags: ${{ steps.meta-pushd.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:latest
labels: ${{ steps.meta-pushd.outputs.labels }}
# stoatchat/voice-ingress
- name: Docker meta
id: meta-voice-ingress
uses: docker/metadata-action@v4
with:
images: |
ghcr.io/stoatchat/voice-ingress
- name: Publish
uses: docker/build-push-action@v4
with:
context: .
push: true
platforms: linux/amd64,linux/arm64
file: crates/daemons/voice-ingress/Dockerfile
tags: ${{ steps.meta-voice-ingress.outputs.tags }}
build-args: |
BASE_IMAGE=ghcr.io/${{ github.repository_owner }}/base:${{ steps.base.outputs.tag }}
labels: ${{ steps.meta-voice-ingress.outputs.labels }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

1
.gitignore vendored
View File

@@ -11,5 +11,4 @@ venv/
.vercel
.DS_Store
livekit.yml
.idea

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,288 +0,0 @@
# Changelog
## [0.13.7](https://github.com/stoatchat/stoatchat/compare/v0.13.6...v0.13.7) (2026-05-21)
### Bug Fixes
* sanitize emoji input to handle variation selectors ([#774](https://github.com/stoatchat/stoatchat/issues/774)) ([2d308e0](https://github.com/stoatchat/stoatchat/commit/2d308e03d58c19f27b5b4d65dc2a15ef20b56190))
* update mention count badge for channel acks ([#769](https://github.com/stoatchat/stoatchat/issues/769)) ([0d9ae50](https://github.com/stoatchat/stoatchat/commit/0d9ae508d9d2199f0e408b8ca634d20489be6f61))
## [0.13.6](https://github.com/stoatchat/stoatchat/compare/v0.13.5...v0.13.6) (2026-05-18)
### Features
* Update FCM payload for android notifications ([#766](https://github.com/stoatchat/stoatchat/issues/766)) ([acbc087](https://github.com/stoatchat/stoatchat/commit/acbc087982e9aeb05cabc5ab4c9b1291f67490ad))
* user slowmode events ([#760](https://github.com/stoatchat/stoatchat/issues/760)) ([af0d8aa](https://github.com/stoatchat/stoatchat/commit/af0d8aad14dc68d88159d0e1c714077d362e21e4))
### Bug Fixes
* include `minio` region as tests need it ([#761](https://github.com/stoatchat/stoatchat/issues/761)) ([298742d](https://github.com/stoatchat/stoatchat/commit/298742dbad4eafae356f976c56b9db23904b0c3a))
* set env var for publishing crates ([#768](https://github.com/stoatchat/stoatchat/issues/768)) ([018afaf](https://github.com/stoatchat/stoatchat/commit/018afaf38f6330d92dad2a68b640c0cb3f6b639a))
* Use proper headers to determine IP when not behind cloudflare ([#764](https://github.com/stoatchat/stoatchat/issues/764)) ([494c8b7](https://github.com/stoatchat/stoatchat/commit/494c8b7cabaae2a51039a7a5b559d5e2e5279554))
* voice ingress crashing due to new Result in AMQP::new_auto() ([#765](https://github.com/stoatchat/stoatchat/issues/765)) ([2871632](https://github.com/stoatchat/stoatchat/commit/2871632382395cb20cbe0047c542d3ac31ff3f03))
### Miscellaneous Chores
* switch to lapin ([#767](https://github.com/stoatchat/stoatchat/issues/767)) ([5b19853](https://github.com/stoatchat/stoatchat/commit/5b1985381ae829a92c80a19e91a414cd9dc4de93))
## [0.13.5](https://github.com/stoatchat/stoatchat/compare/v0.13.4...v0.13.5) (2026-05-17)
### Bug Fixes
* dont panic on hash missing when deleting files ([#755](https://github.com/stoatchat/stoatchat/issues/755)) ([c902077](https://github.com/stoatchat/stoatchat/commit/c902077cf51076fee11712eb732dc8a8f786fc4b))
## [0.13.4](https://github.com/stoatchat/stoatchat/compare/v0.13.3...v0.13.4) (2026-05-16)
### Bug Fixes
* add TLS feature to livekit-api crate ([#753](https://github.com/stoatchat/stoatchat/issues/753)) ([6cfee1f](https://github.com/stoatchat/stoatchat/commit/6cfee1f601c1e084df7c8f1e7a5e8a560d1dd514))
## [0.13.3](https://github.com/stoatchat/stoatchat/compare/v0.13.2...v0.13.3) (2026-05-15)
### Bug Fixes
* don't automatically set up rabbitmq in delta ([#749](https://github.com/stoatchat/stoatchat/issues/749)) ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
* don't declare queues which seem to cause the backend to crash in prod ([7647cfc](https://github.com/stoatchat/stoatchat/commit/7647cfc8d93aba99f5faef13eb3d970097540d76))
## [0.13.2](https://github.com/stoatchat/stoatchat/compare/v0.13.1...v0.13.2) (2026-05-11)
### Bug Fixes
* update default exchange to `revolt.default` ([#746](https://github.com/stoatchat/stoatchat/issues/746)) ([fcb8091](https://github.com/stoatchat/stoatchat/commit/fcb8091cd7a00d7f26c798daa33aae4b923b2a8b))
## [0.13.1](https://github.com/stoatchat/stoatchat/compare/v0.13.0...v0.13.1) (2026-05-10)
### Bug Fixes
* amqprs startup bug ([#744](https://github.com/stoatchat/stoatchat/issues/744)) ([1100eaf](https://github.com/stoatchat/stoatchat/commit/1100eaf46f849f2509ae01ac497556ca33bde778))
## [0.13.0](https://github.com/stoatchat/stoatchat/compare/v0.12.1...v0.13.0) (2026-05-08)
### Features
* add embed support for YouTube Shorts ([#734](https://github.com/stoatchat/stoatchat/issues/734)) ([d46c7f7](https://github.com/stoatchat/stoatchat/commit/d46c7f7f3c04524c0639c3e0a122626f8e0b3bf7))
* add emoji rename endpoint ([#714](https://github.com/stoatchat/stoatchat/issues/714)) ([23ad135](https://github.com/stoatchat/stoatchat/commit/23ad1359834bb7d07a460b8678d6a6ebffc73eb0))
* add legal links to root payload ([#733](https://github.com/stoatchat/stoatchat/issues/733)) ([21d8201](https://github.com/stoatchat/stoatchat/commit/21d82018cf84ab0fdd10613d254b9562aea8eea3))
* add role icon support ([#724](https://github.com/stoatchat/stoatchat/issues/724)) ([841985d](https://github.com/stoatchat/stoatchat/commit/841985d3b994df1c6eefab2fc7ecbd77ab22c493))
* Add webhook endpoints for editing and deleting messages ([#682](https://github.com/stoatchat/stoatchat/issues/682)) ([6f3441c](https://github.com/stoatchat/stoatchat/commit/6f3441cf4acac2a8e6e1bf07a279a153b80f7956))
* automatically sanitise usernames on create/update ([#689](https://github.com/stoatchat/stoatchat/issues/689)) ([e937697](https://github.com/stoatchat/stoatchat/commit/e93769786c7669485a659ee471630740d3cea702))
* blacklist private ip ranges and add january domain blocklist ([#731](https://github.com/stoatchat/stoatchat/issues/731)) ([6b41db9](https://github.com/stoatchat/stoatchat/commit/6b41db984bb491b2e58324309cc70d8c14e0b814))
* Rewrite acks ([#741](https://github.com/stoatchat/stoatchat/issues/741)) ([ab5bd47](https://github.com/stoatchat/stoatchat/commit/ab5bd47a39ee889de0b5ae6e7b560620853daead))
### Bug Fixes
* add new_user_hours to configuration limits ([#729](https://github.com/stoatchat/stoatchat/issues/729)) ([279f5d5](https://github.com/stoatchat/stoatchat/commit/279f5d5fd7af2df55902c706859ec07f569cdb1e))
* add reconnection policy to Redis subscriber to prevent ghost state ([#708](https://github.com/stoatchat/stoatchat/issues/708)) ([057f2bb](https://github.com/stoatchat/stoatchat/commit/057f2bb8b359f8b942741a30ff54eeb8fbe3e0b1))
* docker compose file had personal url in it ([#742](https://github.com/stoatchat/stoatchat/issues/742)) ([0719985](https://github.com/stoatchat/stoatchat/commit/0719985ac5636590f91e6f9ec4b68f3eded70c13))
* don't strip ICC from exif ([#735](https://github.com/stoatchat/stoatchat/issues/735)) ([d76a711](https://github.com/stoatchat/stoatchat/commit/d76a71141f3e508f6308ba52fa28eaeb56fb3438))
* dont send notification in fcm ([#721](https://github.com/stoatchat/stoatchat/issues/721)) ([89171e9](https://github.com/stoatchat/stoatchat/commit/89171e9bd0f15711157e78c6eec0fe7b480de93a))
* encode filenames in redirects ([#737](https://github.com/stoatchat/stoatchat/issues/737)) ([9fd7128](https://github.com/stoatchat/stoatchat/commit/9fd7128f800badbd184baf943d4f799e601201e4))
* january ip redirects & domain resolver ([#738](https://github.com/stoatchat/stoatchat/issues/738)) ([356491e](https://github.com/stoatchat/stoatchat/commit/356491e934b274f9e895df883dd63ef0b3123510))
* update message length validation to remove upper limit ([#723](https://github.com/stoatchat/stoatchat/issues/723)) ([ed4fd5e](https://github.com/stoatchat/stoatchat/commit/ed4fd5ebfe6d0ea534a0898da4afdc1f4e2cd6c5))
* use correct response for NoEffect errors ([#732](https://github.com/stoatchat/stoatchat/issues/732)) ([5378cd2](https://github.com/stoatchat/stoatchat/commit/5378cd22b4c7d85f44c31a6af0dda00941b80d5c))
## [0.12.1](https://github.com/stoatchat/stoatchat/compare/v0.12.0...v0.12.1) (2026-04-10)
### Bug Fixes
* add migration to update existing files to be animated ([#705](https://github.com/stoatchat/stoatchat/issues/705)) ([f2c056a](https://github.com/stoatchat/stoatchat/commit/f2c056a1515be493b195f3f5db5886c2ddf36700))
* don't send self dm notifications ([#706](https://github.com/stoatchat/stoatchat/issues/706)) ([f30b729](https://github.com/stoatchat/stoatchat/commit/f30b729ca90d0be6853c57ab4935694e5e59ae56))
* mise start + missing docker image ([#564](https://github.com/stoatchat/stoatchat/issues/564)) ([fb8fe16](https://github.com/stoatchat/stoatchat/commit/fb8fe1655776791421284a6a093e86f0320c258a))
* test failure due to wrong assertion ([#707](https://github.com/stoatchat/stoatchat/issues/707)) ([f81e329](https://github.com/stoatchat/stoatchat/commit/f81e3291bdd57af9ceedb2987b111acc7051d69c))
## [0.12.0](https://github.com/stoatchat/stoatchat/compare/v0.11.5...v0.12.0) (2026-03-28)
### Features
* add bug report template for issue tracking ([#627](https://github.com/stoatchat/stoatchat/issues/627)) ([f777e28](https://github.com/stoatchat/stoatchat/commit/f777e2863c6ca50057c8b5d0a5be14915d287724))
* Add slowmode functionality to text channels ([#680](https://github.com/stoatchat/stoatchat/issues/680)) ([6107f24](https://github.com/stoatchat/stoatchat/commit/6107f242fd3ebaff71a15f9a16330ffbcb4f2d7b))
* Allow restricting server creation to specific users ([#685](https://github.com/stoatchat/stoatchat/issues/685)) ([edfa97d](https://github.com/stoatchat/stoatchat/commit/edfa97db108c9c81828547f98a1db5315cb5ba4a))
* compute thumbhash for images ([#596](https://github.com/stoatchat/stoatchat/issues/596)) ([c2d4369](https://github.com/stoatchat/stoatchat/commit/c2d4369e160f32d79bce0a0b0f14677f89de3669))
* Detect animation in image files for fetch_preview ([#574](https://github.com/stoatchat/stoatchat/issues/574)) ([3fa0abf](https://github.com/stoatchat/stoatchat/commit/3fa0abf47f5f42ddd8ee041fe4c44fbc5ba800c1))
* expose global and user limits in root API response ([#644](https://github.com/stoatchat/stoatchat/issues/644)) ([0b522eb](https://github.com/stoatchat/stoatchat/commit/0b522ebddc17f2e3f792ff5e2347793e9849fa23))
* implement time based message sweep on user ban ([#670](https://github.com/stoatchat/stoatchat/issues/670)) ([98c7b1b](https://github.com/stoatchat/stoatchat/commit/98c7b1b5a5b9fdac5c0ab83be10f0e23114dbfc9))
* load config from env vars ([#576](https://github.com/stoatchat/stoatchat/issues/576)) ([5191bd1](https://github.com/stoatchat/stoatchat/commit/5191bd16b2a905b8409838e34eb0baca96f08580))
* parse message push notification content and replace internal formatting ([#693](https://github.com/stoatchat/stoatchat/issues/693)) ([d1e72ce](https://github.com/stoatchat/stoatchat/commit/d1e72cee42c54e16f4e49af569897528b10a28ca))
* Transfer ownership ([#396](https://github.com/stoatchat/stoatchat/issues/396)) ([735d644](https://github.com/stoatchat/stoatchat/commit/735d644e043793cb86e74aab5b88bb4b8bc17ba2))
* update livekit ([#698](https://github.com/stoatchat/stoatchat/issues/698)) ([f181edc](https://github.com/stoatchat/stoatchat/commit/f181edc8f2ff3ce4b6d48938dfc73931ecfa2279))
### Bug Fixes
* add flag for disabling events instead of commenting them out ([#695](https://github.com/stoatchat/stoatchat/issues/695)) ([a5cd08a](https://github.com/stoatchat/stoatchat/commit/a5cd08a655dece4269f3ac84fa2387ae356709a5))
* add masquerade permission to default direct message settings ([#665](https://github.com/stoatchat/stoatchat/issues/665)) ([ab52569](https://github.com/stoatchat/stoatchat/commit/ab525699bd6663333f0e9fed6d2455e482e6a09f))
* Check for appropriate permission for removing other users avatar ([#657](https://github.com/stoatchat/stoatchat/issues/657)) ([d56135e](https://github.com/stoatchat/stoatchat/commit/d56135e0cbc713884c9378832952f7ad490fa315))
* default video resolution is a non-existent size ([#601](https://github.com/stoatchat/stoatchat/issues/601)) ([0698e11](https://github.com/stoatchat/stoatchat/commit/0698e115e8d003d615e468c4fb9654e6bbc9107f)), closes [#588](https://github.com/stoatchat/stoatchat/issues/588)
* **docs:** Update GitHub links ([#647](https://github.com/stoatchat/stoatchat/issues/647)) ([b830631](https://github.com/stoatchat/stoatchat/commit/b830631bd25a546844b7bdd30386084bb365e4de))
* don't use a bitop for OR ([#676](https://github.com/stoatchat/stoatchat/issues/676)) ([5701b5c](https://github.com/stoatchat/stoatchat/commit/5701b5c18c513f796af365169ceaea372a22638c))
* Fix typo for p256dh in vapid notification flow ([#622](https://github.com/stoatchat/stoatchat/issues/622)) ([a80ad1c](https://github.com/stoatchat/stoatchat/commit/a80ad1cbe58b8af5e45751e51d94d93c1cea1c9f))
* improve generated openapi.json ([#584](https://github.com/stoatchat/stoatchat/issues/584)) ([52ed510](https://github.com/stoatchat/stoatchat/commit/52ed5100c2446e0b261085639e123e7e124cab2c))
* no node state set on channel creation ([#653](https://github.com/stoatchat/stoatchat/issues/653)) ([24d0d2b](https://github.com/stoatchat/stoatchat/commit/24d0d2b7266f6f8a692d0a52704acfecf517674c))
* only show first line on commit messages ([#696](https://github.com/stoatchat/stoatchat/issues/696)) ([91783b9](https://github.com/stoatchat/stoatchat/commit/91783b906697fc85305dee683f7c15dda55f0c50))
* pass &str to Reference ([#697](https://github.com/stoatchat/stoatchat/issues/697)) ([ccda6f5](https://github.com/stoatchat/stoatchat/commit/ccda6f5c53ee043705f7ff6b5f6c393f020781de))
* redis_url vs redis_uri in config ([#666](https://github.com/stoatchat/stoatchat/issues/666)) ([b0b728f](https://github.com/stoatchat/stoatchat/commit/b0b728fb0dbc9ee28360301de1c3ea501bbbff1d))
* replace some links and Revolt mentions to current Stoat ([#515](https://github.com/stoatchat/stoatchat/issues/515)) ([d629e89](https://github.com/stoatchat/stoatchat/commit/d629e89304be2f0011e189293b278f07d346aa7d))
* send push notifications for DM and group messages ([#660](https://github.com/stoatchat/stoatchat/issues/660)) ([52c0d2f](https://github.com/stoatchat/stoatchat/commit/52c0d2f266b76d8975bba2d5e75c62bb30149c45))
* store server id in redis and in room metadata to be able to delete voice state in all scenarios ([#656](https://github.com/stoatchat/stoatchat/issues/656)) ([49c6289](https://github.com/stoatchat/stoatchat/commit/49c628958070e4f0a5edc764d3b48158589219d9))
* uname is missing from crond ([#675](https://github.com/stoatchat/stoatchat/issues/675)) ([dc4438b](https://github.com/stoatchat/stoatchat/commit/dc4438bc3c7b2cad8d442b3cd438afb9ed566a5e))
## [0.11.5](https://github.com/stoatchat/stoatchat/compare/v0.11.4...v0.11.5) (2026-02-17)
### Reverts
* disable user update events ([#593](https://github.com/stoatchat/stoatchat/issues/593)) ([1c98ead](https://github.com/stoatchat/stoatchat/commit/1c98ead69579b4700be0b51c9020bb8402336cc6))
## [0.11.4](https://github.com/stoatchat/stoatchat/compare/v0.11.3...v0.11.4) (2026-02-16)
### Bug Fixes
* add separate config option for redis events replica url ([#590](https://github.com/stoatchat/stoatchat/issues/590)) ([a75e4ea](https://github.com/stoatchat/stoatchat/commit/a75e4eabfc4b34aba7620c82ba77558a32d9e10a))
## [0.11.3](https://github.com/stoatchat/stoatchat/compare/v0.11.2...v0.11.3) (2026-02-13)
### Bug Fixes
* cut presence traffic too while we engineer a new events architecture ([#561](https://github.com/stoatchat/stoatchat/issues/561)) ([1f8ea96](https://github.com/stoatchat/stoatchat/commit/1f8ea963ad742f693f405e6438f1c343c81e6579))
## [0.11.2](https://github.com/stoatchat/stoatchat/compare/v0.11.1...v0.11.2) (2026-02-13)
### Bug Fixes
* cut events traffic while we engineer a new events architecture ([#559](https://github.com/stoatchat/stoatchat/issues/559)) ([a11986b](https://github.com/stoatchat/stoatchat/commit/a11986ba1ad16b672ff1080913a684567d88adbb))
## [0.11.1](https://github.com/stoatchat/stoatchat/compare/v0.11.0...v0.11.1) (2026-02-13)
### Bug Fixes
* bots in multiple voice channel logic ([#544](https://github.com/stoatchat/stoatchat/issues/544)) ([94cb916](https://github.com/stoatchat/stoatchat/commit/94cb916231b9b8befb2e94065917ff40815bec52))
## [0.11.0](https://github.com/stoatchat/stoatchat/compare/v0.10.3...v0.11.0) (2026-02-10)
### Features
* appeal to the almighty Spamhaus ([#524](https://github.com/stoatchat/stoatchat/issues/524)) ([5132270](https://github.com/stoatchat/stoatchat/commit/5132270f2edd6df25ce414daa42ed1b2aa6fa7a9))
## [0.10.3](https://github.com/stoatchat/stoatchat/compare/v0.10.2...v0.10.3) (2026-02-07)
### Bug Fixes
* update `Revolt` -&gt; `Stoat` in email titles/desc. ([#508](https://github.com/stoatchat/stoatchat/issues/508)) ([84483ce](https://github.com/stoatchat/stoatchat/commit/84483cee7af3e5dfa16f7fe13e334c4d9f5abd60))
## [0.10.2](https://github.com/stoatchat/stoatchat/compare/v0.10.1...v0.10.2) (2026-01-25)
### Bug Fixes
* thumbnailification requires rgb8/rgba8 ([#505](https://github.com/stoatchat/stoatchat/issues/505)) ([413aa04](https://github.com/stoatchat/stoatchat/commit/413aa04dcaf8bff3935ed1e5f31432e11a03ce6f))
## [0.10.1](https://github.com/stoatchat/stoatchat/compare/v0.10.0...v0.10.1) (2026-01-25)
### Bug Fixes
* use Rust 1.92.0 for Docker build ([#503](https://github.com/stoatchat/stoatchat/issues/503)) ([98da8a2](https://github.com/stoatchat/stoatchat/commit/98da8a28a0aa2fee4e8eee1d86bd7c49e3187477))
## [0.10.0](https://github.com/stoatchat/stoatchat/compare/v0.9.4...v0.10.0) (2026-01-25)
### Features
* allow kicking members from voice channels ([#495](https://github.com/stoatchat/stoatchat/issues/495)) ([0dc5442](https://github.com/stoatchat/stoatchat/commit/0dc544249825a49c793309edee5ec1838458a6da))
* repository architecture for files crate w. added tests ([#498](https://github.com/stoatchat/stoatchat/issues/498)) ([01ded20](https://github.com/stoatchat/stoatchat/commit/01ded209c62208fc906d6aab9b08c04e860e10ef))
### Bug Fixes
* expose ratelimit headers via cors ([#496](https://github.com/stoatchat/stoatchat/issues/496)) ([a1a2125](https://github.com/stoatchat/stoatchat/commit/a1a21252d0ad58937e41f16e5fb86f96bebd2a51))
## [0.9.4](https://github.com/stoatchat/stoatchat/compare/v0.9.3...v0.9.4) (2026-01-10)
### Bug Fixes
* checkout repo. before bumping lock ([#490](https://github.com/stoatchat/stoatchat/issues/490)) ([b2da2a8](https://github.com/stoatchat/stoatchat/commit/b2da2a858787853be43136fd526a0bd72baf78ef))
* persist credentials for git repo ([#492](https://github.com/stoatchat/stoatchat/issues/492)) ([c674a9f](https://github.com/stoatchat/stoatchat/commit/c674a9fd4e0abbd51569870e4b38074d4a1de03c))
## [0.9.3](https://github.com/stoatchat/stoatchat/compare/v0.9.2...v0.9.3) (2026-01-10)
### Bug Fixes
* pipeline fixes ([#487](https://github.com/stoatchat/stoatchat/issues/487)) ([aeeafeb](https://github.com/stoatchat/stoatchat/commit/aeeafebefc36a43a656cf797c9251ca50292733c))
## [0.9.2](https://github.com/stoatchat/stoatchat/compare/v0.9.1...v0.9.2) (2026-01-10)
### Bug Fixes
* disable publish for services ([#485](https://github.com/stoatchat/stoatchat/issues/485)) ([d13609c](https://github.com/stoatchat/stoatchat/commit/d13609c37279d6a40445dcd99564e5c3dd03bac1))
## [0.9.1](https://github.com/stoatchat/stoatchat/compare/v0.9.0...v0.9.1) (2026-01-10)
### Bug Fixes
* **ci:** pipeline fixes (marked as fix to force release) ([#483](https://github.com/stoatchat/stoatchat/issues/483)) ([303e52b](https://github.com/stoatchat/stoatchat/commit/303e52b476585eea81c33837f1b01506ce387684))
## [0.9.0](https://github.com/stoatchat/stoatchat/compare/v0.8.8...v0.9.0) (2026-01-10)
### Features
* add id field to role ([#470](https://github.com/stoatchat/stoatchat/issues/470)) ([2afea56](https://github.com/stoatchat/stoatchat/commit/2afea56e56017f02de98e67316b4457568ad5b26))
* add ratelimits to gifbox ([1542047](https://github.com/stoatchat/stoatchat/commit/154204742d21cbeff6e2577b00f50b495ea44631))
* include groups and dms in fetch mutuals ([caa8607](https://github.com/stoatchat/stoatchat/commit/caa86074680d46223cebc20f41e9c91c41ec825d))
* include member payload in ServerMemberJoin event ([480f210](https://github.com/stoatchat/stoatchat/commit/480f210ce85271e13d1dac58a5dae08de108579d))
* initial work on tenor gif searching ([b0c977b](https://github.com/stoatchat/stoatchat/commit/b0c977b324b8144c1152589546eb8fec5954c3e7))
* make message lexer use unowned string ([1561481](https://github.com/stoatchat/stoatchat/commit/1561481eb4cdc0f385fbf0a81e4950408050e11f))
* ready payload field customisation ([db57706](https://github.com/stoatchat/stoatchat/commit/db577067948f13e830b5fb773034e9713a1abaff))
* require auth for search ([b5cd5e3](https://github.com/stoatchat/stoatchat/commit/b5cd5e30ef7d5e56e8964fb7c543965fa6bf5a4a))
* trending and categories routes ([5885e06](https://github.com/stoatchat/stoatchat/commit/5885e067a627b8fff1c8ce2bf9e852ff8cf3f07a))
* voice chats v2 ([#414](https://github.com/stoatchat/stoatchat/issues/414)) ([d567155](https://github.com/stoatchat/stoatchat/commit/d567155f124e4da74115b1a8f810062f7c6559d9))
### Bug Fixes
* add license to revolt-parser ([5335124](https://github.com/stoatchat/stoatchat/commit/53351243064cac8d499dd74284be73928fa78a43))
* allow for disabling default features ([65fbd36](https://github.com/stoatchat/stoatchat/commit/65fbd3662462aed1333b79e59155fa6377e83fcc))
* apple music to use original url instead of metadata url ([bfe4018](https://github.com/stoatchat/stoatchat/commit/bfe4018e436a4075bae780dd4d35a9b58315e12f))
* apply uname fix to january and autumn ([8f9015a](https://github.com/stoatchat/stoatchat/commit/8f9015a6ff181d208d9269ab8691bd417d39811a))
* **ci:** publish images under stoatchat and remove docker hub ([d65c1a1](https://github.com/stoatchat/stoatchat/commit/d65c1a1ab3bdc7e5684b03f280af77d881661a3d))
* correct miniz_oxide in lockfile ([#478](https://github.com/stoatchat/stoatchat/issues/478)) ([5d27a91](https://github.com/stoatchat/stoatchat/commit/5d27a91e901dd2ea3e860aeaed8468db6c5f3214))
* correct shebang for try-tag-and-release ([050ba16](https://github.com/stoatchat/stoatchat/commit/050ba16d4adad5d0fb247867aa3e94e3d42bd12d))
* correct string_cache in lockfile ([#479](https://github.com/stoatchat/stoatchat/issues/479)) ([0b178fc](https://github.com/stoatchat/stoatchat/commit/0b178fc791583064bf9ca94b1d39b42d021e1d79))
* don't remove timeouts when a member leaves a server ([#409](https://github.com/stoatchat/stoatchat/issues/409)) ([e635bc2](https://github.com/stoatchat/stoatchat/commit/e635bc23ec857d648d5705e1a3875d7bc3402b0d))
* don't update the same field while trying to remove it ([f4ee35f](https://github.com/stoatchat/stoatchat/commit/f4ee35fb093ca49f0a64ff4b17fd61587df28145)), closes [#392](https://github.com/stoatchat/stoatchat/issues/392)
* github webhook incorrect payload and formatting ([#468](https://github.com/stoatchat/stoatchat/issues/468)) ([dc9c82a](https://github.com/stoatchat/stoatchat/commit/dc9c82aa4e9667ea6639256c65ac8de37a24d1f7))
* implement Serialize to ClientMessage ([dea0f67](https://github.com/stoatchat/stoatchat/commit/dea0f675dde7a63c7a59b38d469f878b7a8a3af4))
* newly created roles should be ranked the lowest ([947eb15](https://github.com/stoatchat/stoatchat/commit/947eb15771ed6785b3dcd16c354c03ded5e4cbe0))
* permit empty `remove` array in edit requests ([6ad3da5](https://github.com/stoatchat/stoatchat/commit/6ad3da5f35f989a2e7d8e29718b98374248e76af))
* preserve order of replies in message ([#447](https://github.com/stoatchat/stoatchat/issues/447)) ([657a3f0](https://github.com/stoatchat/stoatchat/commit/657a3f08e5d652814bbf0647e089ed9ebb139bbf))
* prevent timing out members which have TimeoutMembers permission ([e36fc97](https://github.com/stoatchat/stoatchat/commit/e36fc9738bac0de4f3fcbccba521f1e3754f7ae7))
* relax settings name regex ([3a34159](https://github.com/stoatchat/stoatchat/commit/3a3415915f0d0fdce1499d47a2b7fa097f5946ea))
* remove authentication tag bytes from attachment download ([32e6600](https://github.com/stoatchat/stoatchat/commit/32e6600272b885c595c094f0bc69459250220dcb))
* rename openapi operation ids ([6048587](https://github.com/stoatchat/stoatchat/commit/6048587d348fbca0dc3a9b47690c56df8fece576)), closes [#406](https://github.com/stoatchat/stoatchat/issues/406)
* respond with 201 if no body in requests ([#465](https://github.com/stoatchat/stoatchat/issues/465)) ([24fedf8](https://github.com/stoatchat/stoatchat/commit/24fedf8c4d9cd3160bdec97aa451520f8beaa739))
* swap to using reqwest for query building ([38dd4d1](https://github.com/stoatchat/stoatchat/commit/38dd4d10797b3e6e397fc219e818f379bdff19f2))
* use `trust_cloudflare` config value instead of env var ([cc7a796](https://github.com/stoatchat/stoatchat/commit/cc7a7962a882e1627fcd0bc75858a017415e8cfc))
* use our own result types instead of tenors types ([a92152d](https://github.com/stoatchat/stoatchat/commit/a92152d86da136997817e797c7af8e38731cdde8))

5585
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace]
resolver = "3"
resolver = "2"
members = [
"crates/delta",
@@ -14,192 +14,9 @@ redis23 = { package = "redis", version = "0.23.3", git = "https://github.com/rev
#authifier = { package = "authifier", version = "1.0.10", path = "../authifier/crates/authifier" }
#rocket_authifier = { package = "rocket_authifier", version = "1.0.10", path = "../authifier/crates/rocket_authifier" }
# I'm 99% sure this is overloading the GitHub worker
# hence builds have been failing since, let's just
# disable it for now. In the future, we could use this
# if we were rolling our own CI (that is now).
[profile.release]
lto = true
[workspace.dependencies]
# Async
async-trait = "0.1.89"
tokio = "1.49.0"
async-channel = "2.3.1"
futures = "0.3.32"
async-tungstenite = "0.17.0"
futures-locks = "0.7.1"
async-lock = "2.8.0"
async-recursion = "1.0.4"
tokio-util = { version = "0.7.18" }
# Error Handling
anyhow = "1.0.100"
thiserror = "2.0.18"
sentry = "0.31.5"
sentry-anyhow = "0.38.1"
# Data Validation
regex = "1.12.3"
validator = "0.16"
# Data Types
uuid = "1.19.0"
ulid = "1.2.1"
nanoid = "0.4.0"
typenum = "1.17.0"
num_enum = "0.6.1"
bitfield = "0.13.2"
# Time
chrono = "0.4.15"
iso8601-timestamp = "0.2.10"
# Data Collections
lru = "0.16.3"
indexmap = "2.13.1"
dashmap = "5.2.0"
moka = "0.12.8"
lru_time_cache = "0.11.11"
deadqueue = "0.2.4"
# Web scraping
scraper = "0.20.0"
encoding_rs = "0.8.34"
# Mail
lettre = "0.10.0-alpha.4"
handlebars = "4.3.0"
# HTTP Requests
reqwest = "0.13.2"
isahc = "1.7"
# Notifications
fcm_v1 = "0.3.0"
web-push = "0.10.0"
revolt_a2 = "0.10"
# Parsing
logos = "0.15"
# SVG rendering
usvg = "0.44.0"
resvg = "0.44.0"
tiny-skia = "0.11.4"
# Logging
log = "0.4.29"
pretty_env_logger = "0.4.0"
# Redis
redis-kiss = { version = "0.1.4", default-features = false }
fred = "8.0.1"
# Serialisation
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = { version = "1", features = ["derive"] }
strum_macros = "0.26.4"
# MongoDB
bson = { version = "2.1.0" }
mongodb = { version = "3.1.0" }
# S3
aws-config = "1.5.5"
aws-sdk-s3 = "1.46.0"
# Axum (HTTP server)
axum-macros = "0.4.1"
axum_typed_multipart = "0.12.1"
axum = "0.7.5"
axum-extra = "0.9"
tower-http = "0.5.2"
# Rocket (HTTP server)
rocket = "0.5.1"
rocket_empty = "0.1.1"
revolt_rocket_okapi = "0.10.0"
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_authifier = "1.0.16"
rocket_prometheus = "0.10.0-rc.3"
# Spec Generation
utoipa = "4.2.3"
revolt_okapi = "0.9.1"
schemars = "0.8.8"
utoipa-scalar = "0.1.0"
# Image Processing
jxl-oxide = "0.12.5"
sha2 = "0.10.8"
kamadak-exif = "0.5.4"
webp = "0.3.0"
image = "0.25.2" # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = "0.1.0"
lcms2 = "6.1.1" # for color profile processing
# File processing
revolt_clamav-client = "0.1.5"
simdutf8 = "0.1.4"
# Content type processing
infer = "0.16.0"
ffprobe = "0.4.0"
imagesize = "0.13.0"
# OpenTelemetry
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = [
"env-filter",
] } # consider https://crates.io/crates/better-tracing
opentelemetry = { version = "0.31.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.31.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.31.0", features = ["logs"] }
opentelemetry-appender-tracing = "0.31.1"
# RabbitMQ
lapin = "4.7.1"
# Voice
livekit-api = "=0.4.23"
livekit-protocol = "=0.7.7"
livekit-runtime = "0.4.0"
# Other Utilities
once_cell = "1.9.0"
config = "0.13.3"
cached = "0.44.0"
rand = "0.8.5"
base64 = "0.21.3"
decancer = "3.3.3"
linkify = "0.8.1"
url-escape = "0.1.1"
revolt_optional_struct = "0.2.0"
unicode-segmentation = "1.10.1"
querystring = "1.1.0"
tempfile = "3.12.0"
aes-gcm = "0.10.3"
auto_ops = "0.3.0"
url = "2.2.2"
impl_ops = "0.1.1"
lazy_static = "1.5.0"
mime = "0.3.17"
totp-lite = "2.0.0"
rust-argon2 = "1.0.0"
base32 = "0.4.0"
sha1 = "0.10.6"
futures-lite = "2.6.1"
# Build Dependencies
vergen = "7.5.0"
# Local packages
revolt-coalesced = { version = "0.13.7", path = "crates/core/coalesced" }
revolt-config = { version = "0.13.7", path = "crates/core/config" }
revolt-database = { version = "0.13.7", path = "crates/core/database" }
revolt-files = { version = "0.13.7", path = "crates/core/files" }
revolt-models = { version = "0.13.7", path = "crates/core/models" }
revolt-parser = { version = "0.13.7", path = "crates/core/parser" }
revolt-permissions = { version = "0.13.7", path = "crates/core/permissions" }
revolt-presence = { version = "0.13.7", path = "crates/core/presence" }
revolt-ratelimits = { version = "0.13.7", path = "crates/core/ratelimits" }
revolt-result = { version = "0.13.7", path = "crates/core/result" }

View File

@@ -1,5 +1,5 @@
# Build Stage
FROM --platform="${BUILDPLATFORM}" rust:1.92.0-slim-bookworm
FROM --platform="${BUILDPLATFORM}" rust:1.86.0-slim-bookworm
USER 0:0
WORKDIR /home/rust/src
@@ -34,7 +34,6 @@ COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
COPY crates/daemons/voice-ingress/Cargo.toml ./crates/daemons/voice-ingress/
RUN sh /tmp/build-image-layer.sh deps
# Build all apps

View File

@@ -1,5 +1,5 @@
# Build Stage
FROM rust:1.92.0-slim-bookworm
FROM rust:1.86.0-slim-bookworm
USER 0:0
WORKDIR /home/rust/src
@@ -30,7 +30,6 @@ COPY crates/services/january/Cargo.toml ./crates/services/january/
COPY crates/services/gifbox/Cargo.toml ./crates/services/gifbox/
COPY crates/daemons/crond/Cargo.toml ./crates/daemons/crond/
COPY crates/daemons/pushd/Cargo.toml ./crates/daemons/pushd/
COPY crates/daemons/voice-ingress/Cargo.toml ./crates/daemons/voice-ingress/
RUN sh /tmp/build-image-layer.sh deps
# Build all apps

View File

@@ -1,15 +1,15 @@
<div align="center">
<h1>
Stoat Backend
Revolt Backend
[![Stars](https://img.shields.io/github/stars/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/stargazers)
[![Forks](https://img.shields.io/github/forks/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/network/members)
[![Pull Requests](https://img.shields.io/github/issues-pr/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/pulls)
[![Issues](https://img.shields.io/github/issues/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/issues)
[![Contributors](https://img.shields.io/github/contributors/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/graphs/contributors)
[![License](https://img.shields.io/github/license/stoatchat/stoatchat?style=flat-square&logoColor=white)](https://github.com/stoatchat/stoatchat/blob/main/LICENSE)
[![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 Stoat service.<br/>
The services and libraries that power the Revolt service.<br/>
<br/>
| Crate | Path | Description | |
@@ -37,19 +37,22 @@ The services and libraries that power the Stoat service.<br/>
Rust 1.86.0 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). This is currently solved by build time options but we are looking for a proper fix.
## Development Guide
Before contributing, make yourself familiar with [our contribution guidelines](https://developers.stoat.chat/developing/contrib/) and the [technical documentation for this project](https://developers.stoat.chat/).
Before 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:
- mise
- Rust toolchain (rustup recommended)
- Docker
- Git
- mold (optional, faster compilation)
> A **default.nix** is available for Nix users!
> Run `nix-shell` to activate mise.
> Just run `nix-shell` and continue.
As a heads-up, the development environment uses the following ports:
@@ -70,20 +73,11 @@ As a heads-up, the development environment uses the following ports:
Now you can clone and build the project:
```bash
git clone https://github.com/stoatchat/stoatchat stoat-backend
cd stoat-backend
mise install
mise build
git clone https://github.com/revoltchat/backend revolt-backend
cd revolt-backend
cargo build
```
> [!TIP]
> You can override `BUILDER` in your `.env` file to run cargo with mold if you installed it:
>
> ```bash
> # .env
> BUILDER = "mold --run cargo"
> ```
A default configuration `Revolt.toml` is present in this project that is suited for development.
If you'd like to change anything, create a `Revolt.overrides.toml` file and specify relevant variables.
@@ -120,7 +114,7 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> - "14672:15672"
> ```
>
> With the corresponding Revolt configuration:
> And corresponding Revolt configuration:
>
> ```toml
> # Revolt.overrides.toml
@@ -132,41 +126,53 @@ If you'd like to change anything, create a `Revolt.overrides.toml` file and spec
> [rabbit]
> port = 14072
> ```
>
> And mise configuration
>
> ```bash
> #.env
> DATABASE_PORT = "14017"
> RABBIT_PORT = "14072"
> REDIS_PORT = "14079"
> ```
Then continue:
```bash
cp livekit.example.yml livekit.yml
# start other necessary services
docker compose up -d
mise start
# run everything together
./scripts/start.sh
# .. or individually
# run the API server
cargo run --bin revolt-delta
# run the events server
cargo run --bin revolt-bonfire
# run the file server
cargo run --bin revolt-autumn
# run the proxy server
cargo run --bin revolt-january
# run the tenor proxy
cargo run --bin revolt-gifbox
# run the push daemon (not usually needed in regular development)
cargo run --bin revolt-pushd
# hint:
# mold -run <cargo build, cargo run, etc...>
# mold -run ./scripts/start.sh
```
You can start a web client by doing the following in another terminal:
You can start a web client by doing the following:
```bash
# if you do not have yarn yet and have a modern Node.js:
corepack enable
# clone the web client and run it:
git clone --recursive https://github.com/stoatchat/for-web stoat-web
cd stoat-web
# refer to stoat-web/README.md for startup, creating an account and loging in
git clone --recursive https://github.com/revoltchat/revite
cd revite
yarn
yarn build:deps
echo "VITE_API_URL=http://local.revolt.chat:14702" > .env.local
yarn dev --port 14701
```
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.
To stop all services, hit (CTRL + c) in the terminal you ran `mise start` and run `mise docker:stop`
## Deployment Guide
### Cutting new crate releases
@@ -195,14 +201,14 @@ Tag and push a new release by running:
just release
```
If you have bumped the crate versions, proceed to [GitHub releases](https://github.com/stoatchat/stoatchat/releases/new) to create a changelog.
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 up -d
docker compose -f docker-compose.db.yml up -d
```
Now run tests for whichever database:
@@ -214,6 +220,6 @@ TEST_DB=MONGODB cargo nextest run
## License
The Stoat backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/stoatchat/stoatchat/blob/main/LICENSE).
The Revolt backend is generally licensed under the [GNU Affero General Public License v3.0](https://github.com/revoltchat/backend/blob/master/LICENSE).
**Individual crates may supply their own licenses!**

View File

@@ -15,7 +15,7 @@ host = "127.0.0.1"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile
# 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"
@@ -26,11 +26,6 @@ january = "http://local.revolt.chat:14705"
voso_legacy = ""
voso_legacy_ws = ""
# Public urls for livekit nodes
# each entry here should have a corresponding entry under `api.livekit.nodes`
[hosts.livekit]
worldwide = "ws://local.revolt.chat:14706"
[api]
[api.smtp]
@@ -45,17 +40,8 @@ port = 14025
use_tls = false
use_starttls = false
[api.livekit]
# Config for livekit nodes
# Make sure to change the secret when deploying
# The key and secret should match the values livekit is using
[api.livekit.nodes.worldwide]
url = "http://livekit"
lat = 0.0
lon = 0.0
key = "worldwide"
secret = "ZjCofRlfm6GGtjlifmNpCDkcQbEIIVC0"
[api.security]
token_secret = "trolt"
[files.s3]
# S3 protocol endpoint

73
STYLE_GUIDE.md Normal file
View File

@@ -0,0 +1,73 @@
# Code Style Guide
Beyond using Cargo format and Clippy, there are some specific code style guidelines laid out in this document for different parts of the project.
## Writing Style
- Shorten "identifier" to "Id" with that exact casing, i.e. Server Id.
## `core/database` crate
w.r.t. `model.rs` files
- All struct definitions must be commented.
```rust
/// Server
pub struct Server {
/// Name of the server
pub name: String,
```
- Struct definitions should not include derives unless necessary (if additional traits such as Hash are required) and instead use `auto_derived!` and `auto_derived_partial!`.
```rust
auto_derived_partial!(
/// Server
pub struct Server { .. },
"PartialServer"
);
```
- `auto_derived!` macro accepts multiple entries and should be used as such:
```rust
auto_derived!(
/// Optional fields on server object
pub enum FieldsServer { .. }
/// Optional fields on server object
pub enum FieldsRole { .. }
);
```
- If special serialisation conditions are required, such as checking if a boolean is false, use the existing definitions for these functions from the crate root:
```rust
#[serde(skip_serializing_if = "crate::if_false", default)]
```
- `impl` blocks may be defined below the struct definitions and should be ordered in the same order of definition. Methods in the block must follow the same guidelines as traits where-in: methods are ordered in terms of CRUD, there are empty line breaks, and methods are commented.
w.r.t. `ops` module for models
- All traits must use a the name format `AbstractPlural` where Plural is the plural form of the collection. e.g. Servers
- Traits defined must follow these guidelines:
- Methods are ordered in terms of CRUD, create-read-update-delete ordering.
```rust
#[async_trait]
pub trait AbstractServerMembers: Sync + Send {
/// Insert a new server member into the database
async fn insert_member(&self, member: &Member) -> Result<()>;
/// Fetch a server member by their id
async fn fetch_member(&self, server_id: &str, user_id: &str) -> Result<Member>;
/// Update information for a server member
async fn update_member(&self, .. ) -> Result<()>;
/// Delete a server member by their id
async fn delete_member(&self, id: &MemberCompositeKey) -> Result<()>;
}
```
- There should be an empty line break between each method declaration.
- All methods must have an appropriate comment.
- When implementing the trait defined in `ops.rs` with each driver, the method declaration style should be the same for ease of searching: same ordering, same comments, same line breaks.

View File

@@ -8,33 +8,18 @@ services:
# MongoDB
database:
image: mongo
command: mongod --replSet rs0
ports:
- "27017:27017"
volumes:
- ./.data/db:/data/db
extra_hosts:
- "host.docker.internal:host-gateway"
healthcheck:
test: echo "try { rs.status() } catch (err) { rs.initiate({_id:'rs0',members:[{_id:0,host:'127.0.0.1:27017'}]}) }" | mongosh --port 27017 --quiet
interval: 5s
timeout: 30s
start_period: 0s
start_interval: 1s
retries: 30
ulimits:
nofile:
soft: 65536
hard: 65536
# MinIO
minio:
image: firstfinger/minio:latest
#command: server /data
image: minio/minio
command: server /data
environment:
MINIO_ROOT_USER: minioautumn
MINIO_ROOT_PASSWORD: minioautumn
MINIO_REGION: minio
volumes:
- ./.data/minio:/data
ports:
@@ -55,7 +40,7 @@ services:
# Rabbit
rabbit:
image: rabbitmq:4-management
image: rabbitmq:3-management
environment:
RABBITMQ_DEFAULT_USER: rabbituser
RABBITMQ_DEFAULT_PASS: rabbitpass
@@ -70,7 +55,7 @@ services:
# Mock SMTP server
maildev:
image: maildev/maildev
image: soulteary/maildev
ports:
- "14025:25"
- "14080:8080"
@@ -79,10 +64,3 @@ services:
MAILDEV_WEB_PORT: 8080
MAILDEV_INCOMING_USER: smtp
MAILDEV_INCOMING_PASS: smtp
livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.13
command: --config /etc/livekit.yml
network_mode: "host"
volumes:
- ./livekit.yml:/etc/livekit.yml

View File

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

View File

@@ -136,7 +136,6 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
channels: false,
members: false,
emojis: false,
voice_states: false,
user_settings: Vec::new(),
channel_unreads: false,
policy_changes: false,
@@ -169,7 +168,6 @@ impl handshake::server::Callback for WebsocketHandshakeCallback {
"channels" => ready_payload_fields.channels = true,
"members" => ready_payload_fields.members = true,
"emojis" => ready_payload_fields.emojis = true,
"voice_states" => ready_payload_fields.voice_states = true,
"channel_unreads" => ready_payload_fields.channel_unreads = true,
"user_settings" => {
if let Some(subkey) = captures.get(1) {

View File

@@ -1,11 +1,9 @@
use std::collections::{HashMap, HashSet};
use std::collections::HashSet;
use futures::future::join_all;
use redis_kiss::AsyncCommands;
use revolt_database::{
events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery,
voice::{get_channel_voice_state, UserVoiceChannel},
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
};
use revolt_models::v0;
@@ -19,9 +17,8 @@ use super::state::{Cache, State};
impl Cache {
/// Check whether the current user can view a channel
pub async fn can_view_channel(&self, db: &Database, channel: &Channel) -> bool {
#[allow(deprecated)]
match &channel {
Channel::TextChannel { server, .. } => {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
let member = self.members.get(server);
let server = self.servers.get(server);
let mut query =
@@ -125,7 +122,12 @@ impl State {
.unwrap_or_default();
// Fetch all memberships with their corresponding servers.
let mut members: Vec<Member> = db.fetch_all_memberships(&user.id).await?;
let members: Vec<Member> = db.fetch_all_memberships(&user.id).await?;
self.cache.members = members
.iter()
.cloned()
.map(|x| (x.id.server.clone(), x))
.collect();
let server_ids: Vec<String> = members.iter().map(|x| x.id.server.clone()).collect();
let servers = db.fetch_servers(&server_ids).await?;
@@ -154,55 +156,6 @@ impl State {
}
}
let voice_states = if fields.voice_states {
let mut voice_state_server_members: HashMap<String, HashSet<String>> = HashMap::new();
// fetch voice states for all the channels we can see
let mut voice_states = Vec::new();
for channel in channels.iter().filter(|c| {
matches!(
c,
Channel::DirectMessage { .. }
| Channel::Group { .. }
| Channel::TextChannel { voice: Some(_), .. }
)
}) {
if let Ok(Some(voice_state)) =
get_channel_voice_state(&UserVoiceChannel::from_channel(channel)).await
{
if let Some(server) = channel.server() {
let set = voice_state_server_members
.entry(server.to_string())
.or_default();
for participant in &voice_state.participants {
user_ids.insert(participant.id.clone());
set.insert(participant.id.clone());
}
} else {
for participant in &voice_state.participants {
user_ids.insert(participant.id.clone());
}
}
voice_states.push(voice_state);
}
}
// Fetch all the members for for the participants who are in a server
for (server, user_ids) in voice_state_server_members {
let user_ids = user_ids.into_iter().collect::<Vec<_>>();
let voice_members = db.fetch_members(&server, &user_ids).await?;
members.extend(voice_members);
}
Some(voice_states)
} else {
None
};
// Fetch presence data for known users.
let online_ids = filter_online(&user_ids.iter().cloned().collect::<Vec<String>>()).await;
@@ -216,12 +169,6 @@ impl State {
)
.await?;
self.cache.members = members
.iter()
.cloned()
.map(|x| (x.id.server.clone(), x))
.collect();
// Fetch customisations.
let emojis = if fields.emojis {
Some(
@@ -321,8 +268,6 @@ impl State {
} else {
None
},
voice_states,
emojis,
user_settings,
channel_unreads,
@@ -340,14 +285,19 @@ impl State {
let id = &id.to_string();
for (channel_id, channel) in &self.cache.channels {
if channel.server() == Some(id) {
channel_ids.insert(channel_id.clone());
match channel {
Channel::TextChannel { server, .. } | Channel::VoiceChannel { server, .. } => {
if server == id {
channel_ids.insert(channel_id.clone());
if self.cache.can_view_channel(db, channel).await {
added_channels.push(channel_id.clone());
} else {
removed_channels.push(channel_id.clone());
if self.cache.can_view_channel(db, channel).await {
added_channels.push(channel_id.clone());
} else {
removed_channels.push(channel_id.clone());
}
}
}
_ => {}
}
}
@@ -402,11 +352,6 @@ impl State {
/// Push presence change to the user and all associated server topics
pub async fn broadcast_presence_change(&self, target: bool) {
let config = revolt_config::config().await;
if config.disable_events_dont_use {
return;
}
if if let Some(status) = &self.cache.users.get(&self.cache.user_id).unwrap().status {
status.presence != Some(Presence::Invisible)
} else {
@@ -520,7 +465,6 @@ impl State {
server,
channels,
emojis: _,
voice_states: _,
} => {
self.insert_subscription(id.clone()).await;

View File

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

View File

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

View File

@@ -1,10 +1,11 @@
use std::{collections::HashSet, net::SocketAddr, sync::Arc};
use async_tungstenite::WebSocketStream;
use authifier::AuthifierEvent;
use fred::{
error::RedisErrorKind,
interfaces::{ClientLike, EventInterface, PubsubInterface},
types::{ReconnectPolicy, RedisConfig},
types::RedisConfig,
};
use futures::{
channel::oneshot,
@@ -12,30 +13,30 @@ use futures::{
stream::{SplitSink, SplitStream},
FutureExt, SinkExt, StreamExt, TryStreamExt,
};
use redis_kiss::{get_connection, AsyncCommands, PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use redis_kiss::{PayloadType, REDIS_PAYLOAD_TYPE, REDIS_URI};
use revolt_config::report_internal_error;
use revolt_database::{
events::{client::EventV1, server::ClientMessage},
util::oauth2,
iso8601_timestamp::Timestamp,
Database, User, UserHint,
};
use revolt_models::v0;
use revolt_presence::{create_session, delete_session};
use tokio::{
use async_std::{
net::TcpStream,
sync::{Mutex, RwLock},
task::spawn,
};
use tokio_util::compat::{TokioAsyncReadCompatExt, Compat};
use revolt_result::create_error;
use sentry::Level;
use crate::config::{ProtocolConfiguration, WebsocketHandshakeCallback};
use crate::events::state::{State, SubscriptionStateChange};
use revolt_models::v0;
type WsReader = SplitStream<WebSocketStream<Compat<TcpStream>>>;
type WsWriter = SplitSink<WebSocketStream<Compat<TcpStream>>, async_tungstenite::tungstenite::Message>;
type WsReader = SplitStream<WebSocketStream<TcpStream>>;
type WsWriter = SplitSink<WebSocketStream<TcpStream>, async_tungstenite::tungstenite::Message>;
/// Start a new WebSocket client worker given access to the database,
/// the relevant TCP stream and the remote address of the client.
@@ -45,7 +46,7 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
// e.g. wss://example.com?format=json&version=1
let (sender, receiver) = oneshot::channel();
let Ok(ws) = async_tungstenite::accept_hdr_async_with_config(
stream.compat(),
stream,
WebsocketHandshakeCallback::from(sender),
None,
)
@@ -89,23 +90,51 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
return;
};
// Presume the token is a proper token first
let (user, session_id) = match User::from_token(db, token, UserHint::Any).await {
Ok(user) => user,
Err(err) => {
write
.send(config.encode(&EventV1::Error { data: err }))
Ok((user, session_id)) => {
db.update_session_last_seen(&session_id, Timestamp::now_utc())
.await
.ok();
return;
(user, session_id)
},
Err(err) => {
let revolt_config = revolt_config::config().await;
// If it fails to find the user from the token see if its an OAuth2 token
let res = match oauth2::decode_token(&revolt_config.api.security.token_secret, token) {
// Check if the OAuth2 token is allowed to establish an events websocket
Ok(claims) => if !claims.scopes.contains(&v0::OAuth2Scope::Events) {
// TODO: maybe a last_seen system for OAuth2 as well
db.fetch_user(&claims.sub).await.map(|user| (user, claims.jti))
} else {
Err(create_error!(MissingScope { scope: v0::OAuth2Scope::Events.to_string() }))
},
// If its expired return an error
Err(e) => if e.into_kind() == oauth2::JWTErrorKind::ExpiredSignature {
Err(create_error!(ExpiredToken))
} else {
// Finally re-return the error from User::from_token if everything else fails to avoid a confusing error
Err(err)
}
};
match res {
Ok(user) => user,
Err(err) => {
write
.send(config.encode(&EventV1::Error { data: err }))
.await
.ok();
return;
}
}
}
};
info!("User {addr:?} authenticated as @{}", user.username);
db.update_session_last_seen(&session_id, Timestamp::now_utc())
.await
.ok();
// Create local state.
let mut state = State::from(user, session_id);
let user_id = state.cache.user_id.clone();
@@ -129,14 +158,6 @@ pub async fn client(db: &'static Database, stream: TcpStream, addr: SocketAddr)
return;
}
let slowmodes = fetch_user_slowmodes(&user_id).await.unwrap_or_default();
if !slowmodes.is_empty() {
let event = EventV1::UserSlowmodes { slowmodes };
if report_internal_error!(write.send(config.encode(&event)).await).is_err() {
return;
}
}
// Create presence session.
let (first_session, session_id) = create_session(&user_id, 0).await;
@@ -227,16 +248,10 @@ async fn listener(
kill_signal_r: async_channel::Receiver<()>,
write: &Mutex<WsWriter>,
) {
let stoat_config = revolt_config::config().await;
let url = stoat_config
.database
.redis_pubsub
.unwrap_or(REDIS_URI.to_string());
let redis_config = RedisConfig::from_url(&url).unwrap();
let mut builder = fred::types::Builder::from_config(redis_config);
builder.set_policy(ReconnectPolicy::new_exponential(8, 100, 30_000, 2));
let subscriber = match report_internal_error!(builder.build_subscriber_client()) {
let redis_config = RedisConfig::from_url(&REDIS_URI).unwrap();
let subscriber = match report_internal_error!(
fred::types::Builder::from_config(redis_config).build_subscriber_client()
) {
Ok(subscriber) => subscriber,
Err(_) => return,
};
@@ -245,21 +260,16 @@ async fn listener(
return;
}
// Let Fred automatically re-subscribe to tracked channels on reconnect.
subscriber.manage_subscriptions();
// Handle Redis connection dropping
let (clean_up_s, clean_up_r) = async_channel::bounded(1);
let clean_up_s = Arc::new(Mutex::new(clean_up_s));
subscriber.on_error(move |err| {
warn!("Redis subscriber error: {:?}", err);
if let RedisErrorKind::Canceled = err.kind() {
let clean_up_s = clean_up_s.clone();
spawn(async move {
clean_up_s.lock().await.send(()).await.ok();
});
}
// Transient errors (IO, timeout) are handled by the reconnect policy.
Ok(())
});
@@ -355,20 +365,22 @@ async fn listener(
break 'out;
};
if let EventV1::DeleteSession { session_id, .. } = &event {
if &state.session_id == session_id {
event = EventV1::Logout;
}
} else if let EventV1::DeleteAllSessions {
exclude_session_id, ..
} = &event
{
if let Some(excluded) = exclude_session_id {
if &state.session_id != excluded {
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 {
event = EventV1::Logout;
}
} else {
let should_send = state.handle_incoming_event_v1(db, &mut event).await;
@@ -440,8 +452,6 @@ async fn worker(
mut read: WsReader,
write: &Mutex<WsWriter>,
) {
let revolt_config = revolt_config::config().await;
loop {
let t1 = read.try_next().fuse();
let t2 = kill_signal_r.recv().fuse();
@@ -478,10 +488,6 @@ async fn worker(
match payload {
ClientMessage::BeginTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
@@ -494,10 +500,6 @@ async fn worker(
.await;
}
ClientMessage::EndTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
@@ -535,42 +537,3 @@ async fn worker(
}
}
}
async fn fetch_user_slowmodes(user_id: &str) -> Option<Vec<v0::ChannelSlowmode>> {
let mut conn = get_connection().await.ok()?.into_inner();
let idx_key = format!("slowmode_idx:{}", user_id);
let channel_ids: Vec<String> = conn.smembers(&idx_key).await.unwrap_or_default();
if channel_ids.is_empty() {
return Some(vec![]);
}
// Bulk fetch all TTLs in one round trip
let mut pipe = redis_kiss::redis::pipe();
for channel_id in &channel_ids {
pipe.ttl(format!("slowmode:{}:{}", user_id, channel_id));
}
let ttls: Vec<i64> = pipe.query_async(&mut conn).await.unwrap_or_default();
// Partition into alive/expired in one pass
let mut slowmodes = vec![];
let mut expired = vec![];
for (channel_id, ttl) in channel_ids.iter().zip(ttls.iter()) {
if *ttl > 0 {
slowmodes.push(v0::ChannelSlowmode {
channel_id: channel_id.clone(),
duration: *ttl as u64,
retry_after: *ttl as u64,
});
} else {
expired.push(channel_id.as_str());
}
}
// Bulk remove all expired members in one SREM call
if !expired.is_empty() {
conn.srem::<_, _, ()>(&idx_key, expired).await.ok();
}
Some(slowmodes)
}

View File

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

View File

@@ -1,11 +1,10 @@
[package]
name = "revolt-config"
version = "0.13.7"
version = "0.8.9"
edition = "2021"
license = "MIT"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Configuration"
repository = "https://github.com/stoatchat/stoatchat"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -13,28 +12,29 @@ repository = "https://github.com/stoatchat/stoatchat"
anyhow = ["dep:sentry-anyhow"]
report-macros = ["revolt-result"]
sentry = ["dep:sentry"]
test = ["tokio"]
default = ["sentry"]
test = ["async-std"]
default = ["test", "sentry"]
[dependencies]
# Utility
config = { workspace = true }
cached = { workspace = true }
config = "0.13.3"
cached = "0.44.0"
once_cell = "1.18.0"
# Serde
serde = { workspace = true }
serde = { version = "1", features = ["derive"] }
# Async
futures-locks = { workspace = true }
tokio = { workspace = true, optional = true }
futures-locks = "0.7.1"
async-std = { version = "1.8.0", features = ["attributes"], optional = true }
# Logging
log = { workspace = true }
pretty_env_logger = { workspace = true }
log = "0.4.14"
pretty_env_logger = "0.4.0"
# Sentry
sentry = { workspace = true, optional = true }
sentry-anyhow = { workspace = true, optional = true }
sentry = { version = "0.31.5", optional = true }
sentry-anyhow = { version = "0.38.1", optional = true }
# Core
revolt-result = { workspace = true, optional = true }
revolt-result = { version = "0.8.9", path = "../result", optional = true }

View File

@@ -1,5 +1,3 @@
environment = "test"
[database]
mongodb = "mongodb://localhost"
redis = "redis://localhost/"
@@ -12,12 +10,3 @@ password = "rabbitpass"
[features]
webhooks_enabled = true
[api.smtp]
host = "localhost"
username = "smtp"
password = "smtp"
from_address = "development@stoat.chat"
reply_to = "support@stoat.chat"
port = 14025
use_tls = false

View File

@@ -1,6 +1,4 @@
production = false
disable_events_dont_use = false
environment = "dev"
[database]
# MongoDB connection URL
@@ -13,7 +11,7 @@ redis = "redis://redis/"
[hosts]
# Web locations of various services
# Defaults assume all services are reverse-proxied
# See https://github.com/stoatchat/self-hosted/blob/main/Caddyfile
# 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"
@@ -24,23 +22,17 @@ january = "http://local.revolt.chat/january"
voso_legacy = ""
voso_legacy_ws = ""
[hosts.livekit]
[rabbit]
host = "rabbit"
port = 5672
username = "rabbituser"
password = "rabbitpass"
default_exchange = "revolt.default"
[rabbit.queues]
acks = "internal.ack"
[api]
[api.registration]
# Whether an invite should be required for registration
# See https://github.com/stoatchat/self-hosted#making-your-instance-invite-only
# See https://github.com/revoltchat/self-hosted#making-your-instance-invite-only
invite_only = false
[api.smtp]
@@ -54,10 +46,6 @@ from_address = "noreply@example.com"
# port = 587
# use_tls = true
[api.smtp.expiry]
expire_verification = 604800 # 3600 * 24 * 7
expire_password_reset = 86400 # 3600 * 24
expire_account_deletion = 86400 # 3600 * 24
[api.security]
# Authifier Shield API key
@@ -68,6 +56,8 @@ voso_legacy_token = ""
trust_cloudflare = false
# easypwned endpoint
easypwned = ""
# Secret used to encode and decode tokens
token_secret = ""
# Tenor API Key
tenor_key = ""
@@ -76,23 +66,12 @@ tenor_key = ""
hcaptcha_key = ""
hcaptcha_sitekey = ""
[api.security.shield]
host = ""
key = ""
[api.workers]
# Maximum concurrent connections (to proxy server)
max_concurrent_connections = 50
[api.livekit]
# How long to ring devices for when calling in dms/groups, in seconds
call_ring_duration = 30
[api.livekit.nodes]
[api.users]
# Minimum allowed length of usernames
min_username_length = 2
[pushd]
# this changes the names of the queues to not overlap
@@ -104,18 +83,12 @@ production = true
# Increasing this will resolve mentions faster, but will consume more memory while resolving.
mass_mention_chunk_size = 200
# How long pushd will cache resolved names for rendered message notifications.
# Increasing this will result in lower database usage, but may result in a situation where a user/channel/role name changes
# and the notifications still resolve to the old name.
render_cache_time = 60
# none of these should need changing
exchange = "revolt.notifications"
message_queue = "notifications.origin.message"
mass_mention_queue = "notifications.origin.mass_mention" # handles messages that contain role or everyone mentions
fr_accepted_queue = "notifications.ingest.fr_accepted" # friend request accepted
fr_received_queue = "notifications.ingest.fr_received" # friend request received
dm_call_queue = "notifications.ingest.dm_call" # direct message voice call
generic_queue = "notifications.ingest.generic" # generic messages (title + body)
ack_queue = "notifications.process.ack" # updates badges for apple devices
@@ -145,8 +118,6 @@ pkcs8 = ""
key_id = ""
team_id = ""
[january]
blocked_domains = []
[files]
# Encryption key for stored files
@@ -243,10 +214,6 @@ new_user_hours = 72
# (should be greater than any one file upload limit)
body_limit_size = 20_000_000
# If any userids are entered here, only those users will be able to create servers.
# Leave empty to allow all users to create servers
restrict_server_creation = []
[features.limits.new_user]
# Limits imposed on new users
@@ -265,18 +232,6 @@ message_attachments = 5
# Maximum number of servers the user can create/join
servers = 50
# Maximum audio frequency (Hz) in voice calls
voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Maximum resolution (width, height) of video streams in voice calls
video_resolution = [1080, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5]
[features.limits.new_user.file_upload_size_limit]
# Maximum file size limits (in bytes)
attachments = 20_000_000
@@ -304,18 +259,6 @@ message_attachments = 5
# Maximum number of servers the user can create/join
servers = 100
# Maximum audio frequency (Hz) in voice calls
voice_quality = 16000
# Whether the user can use video streams in voice calls
video = true
# Maximum resolution (width, height) of video streams in voice calls
video_resolution = [1280, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
video_aspect_ratio = [0.3, 2.5]
[features.limits.default.file_upload_size_limit]
# Maximum file size limits (in bytes)
attachments = 20_000_000
@@ -330,19 +273,12 @@ emojis = 500_000
# default: 5
process_message_delay_limit = 5
[features.legal_links]
# URLs for legal documents
terms_of_service = ""
privacy_policy = ""
guidelines = ""
[sentry]
# Configuration for Sentry error reporting
api = ""
events = ""
voice_ingress = ""
files = ""
proxy = ""
pushd = ""
crond = ""
gifbox = ""
gifbox = ""

View File

@@ -1,10 +1,9 @@
#[cfg(feature = "test")]
use std::sync::OnceLock;
use std::{collections::HashMap, path::Path, sync::LazyLock};
use std::collections::HashMap;
use cached::proc_macro::cached;
use config::{Config, Environment, File, FileFormat};
use config::{Config, File, FileFormat};
use futures_locks::RwLock;
use once_cell::sync::Lazy;
use serde::Deserialize;
#[cfg(feature = "sentry")]
@@ -67,28 +66,13 @@ static CONFIG_SEARCH_PATHS: [&str; 3] = [
static TEST_OVERRIDE_PATH: &str = "Revolt.test-overrides.toml";
/// Configuration builder
static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
RwLock::new({
let mut builder = Config::builder().add_source(File::from_str(
include_str!("../Revolt.toml"),
FileFormat::Toml,
));
let cwd = std::env::current_dir().unwrap();
let mut cwd: Option<&Path> = Some(&cwd);
while let Some(path) = cwd {
for config_path in CONFIG_SEARCH_PATHS {
let config_path = path.join(config_path);
if config_path.exists() {
builder = builder
.add_source(File::new(config_path.to_str().unwrap(), FileFormat::Toml));
}
}
cwd = path.parent();
}
if std::env::var("TEST_DB").is_ok() {
builder = builder.add_source(File::from_str(
include_str!("../Revolt.test.toml"),
@@ -110,7 +94,11 @@ static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
}
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
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()
})
@@ -120,12 +108,6 @@ static CONFIG_BUILDER: LazyLock<RwLock<Config>> = LazyLock::new(|| {
pub struct Database {
pub mongodb: String,
pub redis: String,
pub redis_pubsub: Option<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct RabbitQueues {
pub acks: String,
}
#[derive(Deserialize, Debug, Clone)]
@@ -134,8 +116,6 @@ pub struct Rabbit {
pub port: u16,
pub username: String,
pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
}
#[derive(Deserialize, Debug, Clone)]
@@ -145,7 +125,8 @@ pub struct Hosts {
pub events: String,
pub autumn: String,
pub january: String,
pub livekit: HashMap<String, String>,
pub voso_legacy: String,
pub voso_legacy_ws: String,
}
#[derive(Deserialize, Debug, Clone)]
@@ -163,18 +144,6 @@ pub struct ApiSmtp {
pub port: Option<i32>,
pub use_tls: Option<bool>,
pub use_starttls: Option<bool>,
pub expiry: EmailExpiry,
}
/// Email expiration config
#[derive(Deserialize, Debug, Clone)]
pub struct EmailExpiry {
/// How long email verification codes should last for (in seconds)
pub expire_verification: i64,
/// How long password reset codes should last for (in seconds)
pub expire_password_reset: i64,
/// How long account deletion codes should last for (in seconds)
pub expire_account_deletion: i64,
}
#[derive(Deserialize, Debug, Clone)]
@@ -214,19 +183,14 @@ pub struct ApiSecurityCaptcha {
pub hcaptcha_sitekey: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurityShield {
pub host: String,
pub key: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiSecurity {
pub shield: ApiSecurityShield,
pub authifier_shield_key: String,
pub voso_legacy_token: String,
pub captcha: ApiSecurityCaptcha,
pub trust_cloudflare: bool,
pub easypwned: String,
pub token_secret: String,
pub tenor_key: String,
}
@@ -235,29 +199,9 @@ pub struct ApiWorkers {
pub max_concurrent_connections: usize,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiLiveKit {
pub call_ring_duration: usize,
pub nodes: HashMap<String, LiveKitNode>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LiveKitNode {
pub url: String,
pub lat: f64,
pub lon: f64,
pub key: String,
pub secret: String,
// whether to hide the node in the nodes list
#[serde(default)]
pub private: bool,
}
#[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
}
#[derive(Deserialize, Debug, Clone)]
@@ -266,7 +210,6 @@ pub struct Api {
pub smtp: ApiSmtp,
pub security: ApiSecurity,
pub workers: ApiWorkers,
pub livekit: ApiLiveKit,
pub users: ApiUsers,
}
@@ -275,12 +218,10 @@ pub struct Pushd {
pub production: bool,
pub exchange: String,
pub mass_mention_chunk_size: usize,
pub render_cache_time: usize,
// Queues
pub message_queue: String,
pub mass_mention_queue: String,
pub dm_call_queue: String,
pub fr_accepted_queue: String,
pub fr_received_queue: String,
pub generic_queue: String,
@@ -311,10 +252,6 @@ impl Pushd {
self.get_routing_key(self.mass_mention_queue.clone())
}
pub fn get_dm_call_routing_key(&self) -> String {
self.get_routing_key(self.dm_call_queue.clone())
}
pub fn get_fr_accepted_routing_key(&self) -> String {
self.get_routing_key(self.fr_accepted_queue.clone())
}
@@ -328,11 +265,6 @@ impl Pushd {
}
}
#[derive(Deserialize, Debug, Clone)]
pub struct January {
pub blocked_domains: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FilesLimit {
pub min_file_size: usize,
@@ -377,8 +309,6 @@ pub struct GlobalLimits {
pub new_user_hours: usize,
pub body_limit_size: usize,
pub restrict_server_creation: Vec<String>,
}
#[derive(Deserialize, Debug, Clone)]
@@ -389,10 +319,6 @@ pub struct FeaturesLimits {
pub message_length: usize,
pub message_attachments: usize,
pub servers: usize,
pub voice_quality: u32,
pub video: bool,
pub video_resolution: [u32; 2],
pub video_aspect_ratio: [f32; 2],
pub file_upload_size_limit: HashMap<String, usize>,
}
@@ -408,16 +334,6 @@ pub struct FeaturesLimitsCollection {
pub roles: HashMap<String, FeaturesLimits>,
}
#[derive(Deserialize, Debug, Clone)]
pub struct LegalLinks {
/// Terms of Service URL
pub terms_of_service: String,
/// Privacy Policy URL
pub privacy_policy: String,
/// Guidelines URL
pub guidelines: String,
}
#[derive(Deserialize, Debug, Clone)]
pub struct FeaturesAdvanced {
#[serde(default)]
@@ -435,7 +351,6 @@ impl Default for FeaturesAdvanced {
#[derive(Deserialize, Debug, Clone)]
pub struct Features {
pub limits: FeaturesLimitsCollection,
pub legal_links: LegalLinks,
pub webhooks_enabled: bool,
pub mass_mentions_send_notifications: bool,
pub mass_mentions_enabled: bool,
@@ -448,7 +363,6 @@ pub struct Features {
pub struct Sentry {
pub api: String,
pub events: String,
pub voice_ingress: String,
pub files: String,
pub proxy: String,
pub pushd: String,
@@ -463,13 +377,10 @@ pub struct Settings {
pub hosts: Hosts,
pub api: Api,
pub pushd: Pushd,
pub january: January,
pub files: Files,
pub features: Features,
pub sentry: Sentry,
pub production: bool,
pub environment: String,
pub disable_events_dont_use: bool,
}
impl Settings {
@@ -495,52 +406,23 @@ pub async fn read() -> Config {
CONFIG_BUILDER.read().await.clone()
}
pub async fn config_no_cache() -> Settings {
#[cached(time = 30)]
pub async fn config() -> Settings {
let mut config = read().await.try_deserialize::<Settings>().unwrap();
// inject REDIS_URI for redis-kiss library
if std::env::var("REDIS_URI").is_err() {
if std::env::var("REDIS_URL").is_err() {
std::env::set_var("REDIS_URI", config.database.redis.clone());
}
// auto-detect production nodes
if config.hosts.api.contains("https")
&& (config.hosts.api.contains("revolt.chat") || config.hosts.api.contains("stoat.chat"))
{
if config.hosts.api.contains("https") && config.hosts.api.contains("revolt.chat") {
config.production = true;
}
config
}
#[cached(time = 30)]
pub async fn config() -> Settings {
#[cfg(feature = "test")]
if let Some(overwrites) = CONFIG_OVERWRITES.get() {
return overwrites.clone();
}
config_no_cache().await
}
#[cfg(feature = "test")]
static CONFIG_OVERWRITES: OnceLock<Settings> = OnceLock::new();
/// Modify the config values for a test, this can only be called once
///
/// This will also fail if two or more tests are running in the same process and both try to modify the config,
/// This could happen if tests where run under `cargo test` instead of `nextest`.
#[cfg(feature = "test")]
pub async fn overwrite_config(f: impl FnOnce(&mut Settings)) {
let mut config = config_no_cache().await;
f(&mut config);
CONFIG_OVERWRITES.set(config).expect(
"Cannot overwrite config multiple times, make sure you are running tests through nextest.",
);
}
/// Configure logging and common Rust variables
#[cfg(feature = "sentry")]
pub async fn setup_logging(release: &'static str, dsn: String) -> Option<sentry::ClientInitGuard> {
@@ -586,7 +468,7 @@ macro_rules! configure {
mod tests {
use crate::init;
#[tokio::test]
#[async_std::test]
async fn it_works() {
init().await;
}

View File

@@ -1,113 +1,100 @@
[package]
name = "revolt-database"
version = "0.13.7"
version = "0.8.9"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: Database Implementation"
repository = "https://github.com/stoatchat/stoatchat"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[features]
# Databases
mongodb = ["dep:mongodb", "bson"]
mongodb = ["dep:mongodb", "bson", "authifier/database-mongodb"]
# ... Other
tasks = ["isahc", "linkify", "url-escape"]
tokio-runtime = ["tokio"]
rocket-impl = [
"rocket",
"schemars",
"revolt_okapi",
"revolt_rocket_okapi",
]
axum-impl = ["axum", "revolt-result/axum", "utoipa"]
async-std-runtime = ["async-std", "authifier/async-std-runtime"]
rocket-impl = ["rocket", "schemars", "revolt_okapi", "revolt_rocket_okapi", "authifier/rocket_impl"]
axum-impl = ["axum"]
redis-is-patched = ["revolt-presence/redis-is-patched"]
voice = ["livekit-api", "livekit-protocol", "livekit-runtime"]
# Default Features
default = ["mongodb", "tokio-runtime", "tasks"]
default = ["mongodb", "async-std-runtime", "tasks"]
[dependencies]
# Core
revolt-config = { workspace = true, features = ["report-macros"] }
revolt-result = { workspace = true }
revolt-models = { workspace = true, features = ["validator"] }
revolt-presence = { workspace = true }
revolt-permissions = { workspace = true, features = ["serde", "bson"] }
revolt-parser = { workspace = true }
revolt-coalesced = { workspace = true }
revolt-config = { version = "0.8.9", path = "../config", features = [
"report-macros",
] }
revolt-result = { version = "0.8.9", path = "../result" }
revolt-models = { version = "0.8.9", path = "../models", features = [
"validator",
] }
revolt-presence = { version = "0.8.9", path = "../presence" }
revolt-permissions = { version = "0.8.9", path = "../permissions", features = [
"serde",
"bson",
] }
revolt-parser = { version = "0.8.9", path = "../parser" }
# Utility
log = { workspace = true }
lru = { workspace = true }
rand = { workspace = true }
ulid = { workspace = true }
nanoid = { workspace = true }
base64 = { workspace = true }
once_cell = { workspace = true }
indexmap = { workspace = true }
decancer = { workspace = true }
deadqueue = { workspace = true }
linkify = { workspace = true, optional = true }
url-escape = { workspace = true, optional = true }
validator = { workspace = true, features = ["derive"] }
isahc = { workspace = true, features = ["json"], optional = true }
base32 = { workspace = true }
sha1 = { workspace = true }
log = "0.4"
lru = "0.11.0"
rand = "0.8.5"
ulid = "1.0.0"
nanoid = "0.4.0"
base64 = "0.21.3"
once_cell = "1.17"
indexmap = "1.9.1"
decancer = "1.6.2"
deadqueue = "0.2.4"
linkify = { optional = true, version = "0.8.1" }
url-escape = { optional = true, version = "0.1.1" }
validator = { version = "0.16", features = ["derive"] }
isahc = { optional = true, version = "1.7", features = ["json"] }
jsonwebtoken = "9.3.1"
chrono = "0.4"
# Serialisation
serde_json = { workspace = true }
revolt_optional_struct = { workspace = true }
serde = { workspace = true }
iso8601-timestamp = { workspace = true, features = ["serde", "bson"] }
serde_json = "1"
revolt_optional_struct = "0.2.0"
serde = { version = "1", features = ["derive"] }
iso8601-timestamp = { version = "0.2.10", features = ["serde", "bson"] }
# Events
redis-kiss = { workspace = true, default-features = false, features = ["tokio-runtime"] }
redis-kiss = { version = "0.1.4" }
# Database
bson = { workspace = true, optional = true }
mongodb = { workspace = true, optional = true }
bson = { optional = true, version = "2.1.0" }
mongodb = { optional = true, version = "3.1.0" }
# Database Migration
unicode-segmentation = { workspace = true }
regex = { workspace = true }
unicode-segmentation = "1.10.1"
regex = "1"
# Async Language Features
futures = { workspace = true }
async-lock = { workspace = true }
async-trait = { workspace = true }
async-recursion = { workspace = true }
futures = "0.3.19"
async-lock = "2.8.0"
async-trait = "0.1.51"
async-recursion = "1.0.4"
# Async
tokio = { workspace = true, optional = true }
async-std = { version = "1.8.0", features = ["attributes"], optional = true }
# Axum Impl
axum = { workspace = true, optional = true }
utoipa = { workspace = true, features = ["axum_extras"], optional = true }
axum = { version = "0.7.5", optional = true }
# Rocket Impl
schemars = { workspace = true, optional = true }
rocket = { workspace = true, features = ["json"], optional = true }
revolt_okapi = { workspace = true, optional = true }
revolt_rocket_okapi = { workspace = true, optional = true }
schemars = { version = "0.8.8", optional = true }
rocket = { version = "0.5.1", default-features = false, features = [
"json",
], optional = true }
revolt_okapi = { version = "0.9.1", optional = true }
revolt_rocket_okapi = { version = "0.10.0", optional = true }
# Authifier
authifier = { version = "1.0.15" }
# RabbitMQ
lapin = { workspace = true, features = ["tokio"] }
# Voice
livekit-api = { workspace = true, features = ["rustls-tls-native-roots"], optional = true }
livekit-protocol = { workspace = true, optional = true }
livekit-runtime = { workspace = true, features = ["tokio"], optional = true }
# Security
totp-lite = { workspace = true }
rust-argon2 = { workspace = true }
# Email
lettre = { workspace = true }
handlebars = { workspace = true }
# Web Requests
reqwest = { workspace = true, features = ["json", "form"] }
amqprs = { version = "1.7.0" }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -47,7 +47,6 @@
],
"roles": {
"__ID:5__": {
"_id": "__ID:5__",
"name": "Moderator",
"permissions": {
"a": 545270208,
@@ -56,7 +55,6 @@
"rank": 1
},
"__ID:6__": {
"_id": "__ID:6__",
"name": "Owner",
"permissions": {
"a": 0,

View File

@@ -1,79 +1,30 @@
use std::collections::HashSet;
use std::sync::Arc;
use crate::events::rabbit::*;
use crate::User;
use lapin::{
options::BasicPublishOptions,
protocol::basic::AMQPProperties,
types::{AMQPValue, FieldTable},
Channel, Connection, ConnectionProperties, Error as AMQPError,
};
use 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 revolt_result::Result;
use serde_json::to_string;
#[derive(Clone)]
pub struct AMQP {
friend_request_accepted: Arc<Channel>,
friend_request_received: Arc<Channel>,
generic_message: Arc<Channel>,
message_sent: Arc<Channel>,
mass_mention_message_sent: Arc<Channel>,
ack_notification_message: Arc<Channel>,
dm_call_updated: Arc<Channel>,
process_ack: Arc<Channel>,
#[allow(unused)]
connection: Arc<Connection>,
connection: Connection,
channel: Channel,
}
impl AMQP {
pub async fn new(connection: Arc<Connection>) -> Self {
Self {
friend_request_accepted: Self::create_channel(&connection).await,
friend_request_received: Self::create_channel(&connection).await,
generic_message: Self::create_channel(&connection).await,
message_sent: Self::create_channel(&connection).await,
mass_mention_message_sent: Self::create_channel(&connection).await,
ack_notification_message: Self::create_channel(&connection).await,
dm_call_updated: Self::create_channel(&connection).await,
process_ack: Self::create_channel(&connection).await,
pub fn new(connection: Connection, channel: Channel) -> AMQP {
AMQP {
connection,
channel,
}
}
pub async fn new_auto() -> Self {
let config = revolt_config::config().await;
let connection = Arc::new(
Connection::connect(
&format!(
"amqp://{}:{}@{}:{}",
&config.rabbit.username,
&config.rabbit.password,
&config.rabbit.host,
&config.rabbit.port,
),
ConnectionProperties::default(),
)
.await
.expect("Failed to connect to RabbitMQ"),
);
Self::new(connection).await
}
async fn create_channel(connection: &Connection) -> Arc<Channel> {
Arc::new(
connection
.create_channel()
.await
.expect("Failed to create channel"),
)
}
pub async fn friend_request_accepted(
&self,
accepted_request_user: &User,
@@ -91,20 +42,19 @@ impl AMQP {
config.pushd.get_fr_accepted_routing_key(),
payload
);
self.friend_request_accepted
self.channel
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_fr_accepted_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
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?;
Ok(())
.await
}
pub async fn friend_request_received(
@@ -125,19 +75,19 @@ impl AMQP {
payload
);
self.friend_request_received
self.channel
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_fr_received_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
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?;
Ok(())
.await
}
pub async fn generic_message(
@@ -162,19 +112,19 @@ impl AMQP {
payload
);
self.generic_message
self.channel
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_generic_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
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?;
Ok(())
.await
}
pub async fn message_sent(
@@ -205,19 +155,19 @@ impl AMQP {
payload
);
self.message_sent
self.channel
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_message_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
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?;
Ok(())
.await
}
pub async fn mass_mention_message_sent(
@@ -240,24 +190,19 @@ impl AMQP {
routing_key, payload
);
self.mass_mention_message_sent
self.channel
.basic_publish(
config.pushd.exchange.clone().into(),
routing_key.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
.finish(),
payload.into(),
BasicPublishArguments::new(&config.pushd.exchange, routing_key.as_str()),
)
.await?;
Ok(())
.await
}
/// # Sends an ack to pushd to update badges on iPhones.
/// Not to be confused with the process_ack function, which handles sending all acks to crond for processing.
pub async fn ack_notification_message(
pub async fn ack_message(
&self,
user_id: String,
channel_id: String,
@@ -277,106 +222,22 @@ impl AMQP {
config.pushd.ack_queue, payload
);
let mut headers = FieldTable::default();
let mut headers = FieldTable::new();
headers.insert(
"x-deduplication-header".into(),
AMQPValue::LongString(format!("{}-{}", &user_id, &channel_id).into()),
"x-deduplication-header".try_into().unwrap(),
format!("{}-{}", &user_id, &channel_id).into(),
);
self.ack_notification_message
self.channel
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.ack_queue.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
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?;
Ok(())
}
/// # DM Call Update
/// Used to send an update about a DM call, eg. start or end of a call.
/// Recipients can be used to narrow the scope of recipients, otherwise all recipients will be notified.
/// `ended` refers to the ringing period, not necessarily the call itself.
pub async fn dm_call_updated(
&self,
initiator_id: &str,
channel_id: &str,
started_at: Option<&str>,
ended: bool,
recipients: Option<Vec<String>>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = InternalDmCallPayload {
payload: DmCallPayload {
initiator_id: initiator_id.to_string(),
channel_id: channel_id.to_string(),
started_at: started_at.map(|f| f.to_string()),
ended,
},
recipients,
};
let payload = to_string(&payload).unwrap();
debug!(
"Sending dm call update payload on channel {}: {}",
config.pushd.get_dm_call_routing_key(),
payload
);
self.dm_call_updated
.basic_publish(
config.pushd.exchange.clone().into(),
config.pushd.get_dm_call_routing_key().into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
}
/// # Send an ack to crond for processing
pub async fn process_ack(
&self,
user_id: &str,
channel_id: Option<&str>,
server_id: Option<&str>,
) -> Result<(), AMQPError> {
let config = revolt_config::config().await;
let payload = AckEventPayload {
user_id: user_id.to_string(),
channel_id: channel_id.map(|value| value.to_string()),
server_id: server_id.map(|value| value.to_string()),
};
let payload = to_string(&payload).unwrap();
info!(
"Sending ack processor event on exchange {}, channel {}: {}",
config.rabbit.default_exchange, config.rabbit.queues.acks, payload
);
self.process_ack
.basic_publish(
config.rabbit.default_exchange.clone().into(),
config.rabbit.queues.acks.into(),
BasicPublishOptions::default(),
payload.as_bytes(),
AMQPProperties::default()
.with_content_type("application/json".into())
.with_delivery_mode(2),
)
.await?;
Ok(())
.await
}
}

View File

@@ -2,7 +2,15 @@
mod mongodb;
mod reference;
use authifier::config::Captcha;
use authifier::config::EmailVerificationConfig;
use authifier::config::PasswordScanning;
use authifier::config::ResolveIp;
use authifier::config::SMTPSettings;
use authifier::config::Shield;
use authifier::config::Template;
use authifier::config::Templates;
use authifier::Authifier;
use rand::Rng;
use revolt_config::config;
@@ -27,7 +35,7 @@ pub enum DatabaseInfo {
}
/// Database
#[derive(Clone, Debug)]
#[derive(Clone)]
pub enum Database {
/// Mock database
Reference(ReferenceDb),
@@ -61,7 +69,7 @@ impl DatabaseInfo {
.await;
#[cfg(not(feature = "mongodb"))]
return Err("MongoDB not enabled.".to_string());
return Err("MongoDB not enabled.".to_string())
} else {
DatabaseInfo::Reference.connect().await
}
@@ -82,7 +90,7 @@ impl DatabaseInfo {
.await;
#[cfg(not(feature = "mongodb"))]
return Err("MongoDB not enabled.".to_string());
return Err("MongoDB not enabled.".to_string())
}
_ => unreachable!("must specify REFERENCE or MONGODB"),
}
@@ -103,3 +111,139 @@ impl DatabaseInfo {
}
}
}
impl Database {
/// Create an Authifier reference
pub async fn to_authifier(self) -> Authifier {
let config = config().await;
let mut auth_config = authifier::Config {
password_scanning: if config.api.security.easypwned.is_empty() {
Default::default()
} else {
PasswordScanning::EasyPwned {
endpoint: config.api.security.easypwned,
}
},
email_verification: if !config.api.smtp.host.is_empty() {
EmailVerificationConfig::Enabled {
smtp: SMTPSettings {
from: config.api.smtp.from_address,
host: config.api.smtp.host,
username: config.api.smtp.username,
password: config.api.smtp.password,
reply_to: Some(
config
.api
.smtp
.reply_to
.unwrap_or("support@revolt.chat".into()),
),
port: config.api.smtp.port,
use_tls: config.api.smtp.use_tls,
use_starttls: config.api.smtp.use_starttls,
},
expiry: Default::default(),
templates: if config.production {
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()),
},
reset_existing: Template {
title: "You already have a Revolt account, reset your password."
.into(),
text: include_str!("../../templates/reset-existing.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: Some(
include_str!("../../templates/reset-existing.html").into(),
),
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.txt").into(),
url: format!("{}/delete/", config.hosts.app),
html: Some(include_str!("../../templates/deletion.html").into()),
},
welcome: None,
}
} else {
Templates {
verify: Template {
title: "Verify your account.".into(),
text: include_str!("../../templates/verify.whitelabel.txt").into(),
url: format!("{}/login/verify/", config.hosts.app),
html: None,
},
reset: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
reset_existing: Template {
title: "Reset your password.".into(),
text: include_str!("../../templates/reset.whitelabel.txt").into(),
url: format!("{}/login/reset/", config.hosts.app),
html: None,
},
deletion: Template {
title: "Confirm account deletion.".into(),
text: include_str!("../../templates/deletion.whitelabel.txt")
.into(),
url: format!("{}/delete/", config.hosts.app),
html: None,
},
welcome: None,
}
},
}
} else {
EmailVerificationConfig::Disabled
},
..Default::default()
};
auth_config.invite_only = config.api.registration.invite_only;
if !config.api.security.captcha.hcaptcha_key.is_empty() {
auth_config.captcha = Captcha::HCaptcha {
secret: config.api.security.captcha.hcaptcha_key,
};
}
if !config.api.security.authifier_shield_key.is_empty() {
auth_config.shield = Shield::Enabled {
api_key: config.api.security.authifier_shield_key,
strict: false,
};
}
if config.api.security.trust_cloudflare {
auth_config.resolve_ip = ResolveIp::Cloudflare;
}
Authifier {
database: match self {
Database::Reference(_) => Default::default(),
#[cfg(feature = "mongodb")]
Database::MongoDb(MongoDb(client, _)) => authifier::Database::MongoDb(
authifier::database::MongoDb(client.database("revolt")),
),
},
config: auth_config,
#[cfg(feature = "tasks")]
event_channel: Some(crate::tasks::authifier_relay::sender()),
#[cfg(not(feature = "tasks"))]
event_channel: None,
}
}
}

View File

@@ -11,7 +11,6 @@ use serde::Serialize;
database_derived!(
/// MongoDB implementation
#[derive(Debug)]
pub struct MongoDb(pub ::mongodb::Client, pub String);
);

View File

@@ -5,13 +5,14 @@ use futures::lock::Mutex;
use crate::{
Bot, Channel, ChannelCompositeKey, ChannelUnread, Emoji, File, FileHash, Invite, Member,
MemberCompositeKey, Message, PolicyChange, RatelimitEvent, Report, Server, ServerBan, Snapshot,
User, UserSettings, Webhook, Account, AccountInvite, Session, MFATicket
User, UserSettings, Webhook, AuthorizedBotId, AuthorizedBot,
};
database_derived!(
/// Reference implementation
#[derive(Default, Debug)]
#[derive(Default)]
pub struct ReferenceDb {
pub authorized_bots: Arc<Mutex<HashMap<AuthorizedBotId, AuthorizedBot>>>,
pub bots: Arc<Mutex<HashMap<String, Bot>>>,
pub channels: Arc<Mutex<HashMap<String, Channel>>>,
pub channel_invites: Arc<Mutex<HashMap<String, Invite>>>,
@@ -30,9 +31,5 @@ database_derived!(
pub servers: Arc<Mutex<HashMap<String, Server>>>,
pub safety_reports: Arc<Mutex<HashMap<String, Report>>>,
pub safety_snapshots: Arc<Mutex<HashMap<String, Snapshot>>>,
pub accounts: Arc<Mutex<HashMap<String, Account>>>,
pub account_invites: Arc<Mutex<HashMap<String, AccountInvite>>>,
pub sessions: Arc<Mutex<HashMap<String, Session>>>,
pub tickets: Arc<Mutex<HashMap<String, MFATicket>>>,
}
);

View File

@@ -1,16 +1,15 @@
use authifier::AuthifierEvent;
use revolt_result::Error;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, ChannelSlowmode, ChannelUnread, ChannelVoiceState, Emoji,
FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser,
FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialEmoji,
PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState,
PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings,
UserVoiceState, Webhook,
AppendMessage, Channel, ChannelUnread, Emoji, FieldsChannel, FieldsMember, FieldsMessage,
FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message,
PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser,
PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, Webhook,
};
use crate::{Account, Database, Session};
use crate::Database;
/// Ping Packet
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -28,7 +27,6 @@ pub struct ReadyPayloadFields {
pub channels: bool,
pub members: bool,
pub emojis: bool,
pub voice_states: bool,
pub user_settings: Vec<String>,
pub channel_unreads: bool,
pub policy_changes: bool,
@@ -42,7 +40,6 @@ impl Default for ReadyPayloadFields {
channels: true,
members: true,
emojis: true,
voice_states: true,
user_settings: Vec::new(),
channel_unreads: false,
policy_changes: true,
@@ -55,13 +52,9 @@ impl Default for ReadyPayloadFields {
#[serde(tag = "type")]
pub enum EventV1 {
/// Multiple events
Bulk {
v: Vec<EventV1>,
},
Bulk { v: Vec<EventV1> },
/// Error event
Error {
data: Error,
},
Error { data: Error },
/// Successfully authenticated
Authenticated,
@@ -79,8 +72,6 @@ pub enum EventV1 {
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>,
@@ -92,9 +83,7 @@ pub enum EventV1 {
},
/// Ping response
Pong {
data: Ping,
},
Pong { data: Ping },
/// New message
Message(Message),
@@ -115,10 +104,7 @@ pub enum EventV1 {
},
/// Delete message
MessageDelete {
id: String,
channel: String,
},
MessageDelete { id: String, channel: String },
/// New reaction to a message
MessageReact {
@@ -144,10 +130,7 @@ pub enum EventV1 {
},
/// Bulk delete messages
BulkMessageDelete {
channel: String,
ids: Vec<String>,
},
BulkMessageDelete { channel: String, ids: Vec<String> },
/// New server
ServerCreate {
@@ -155,7 +138,6 @@ pub enum EventV1 {
server: Server,
channels: Vec<Channel>,
emojis: Vec<Emoji>,
voice_states: Vec<ChannelVoiceState>,
},
/// Update existing server
@@ -167,9 +149,7 @@ pub enum EventV1 {
},
/// Delete server
ServerDelete {
id: String,
},
ServerDelete { id: String },
/// Update existing server member
ServerMemberUpdate {
@@ -205,16 +185,10 @@ pub enum EventV1 {
},
/// Server role deleted
ServerRoleDelete {
id: String,
role_id: String,
},
ServerRoleDelete { id: String, role_id: String },
/// Server roles ranks updated
ServerRoleRanksUpdate {
id: String,
ranks: Vec<String>,
},
ServerRoleRanksUpdate { id: String, ranks: Vec<String> },
/// Update existing user
UserUpdate {
@@ -226,15 +200,9 @@ pub enum EventV1 {
},
/// Relationship with another user changed
UserRelationship {
id: String,
user: User,
},
UserRelationship { id: String, user: User },
/// Settings updated remotely
UserSettingsUpdate {
id: String,
update: UserSettings,
},
UserSettingsUpdate { id: String, update: UserSettings },
/// User has been platform banned or deleted their account
///
@@ -245,23 +213,12 @@ pub enum EventV1 {
/// - Server Memberships
///
/// User flags are specified to explain why a wipe is occurring though not all reasons will necessarily ever appear.
UserPlatformWipe {
user_id: String,
flags: i32,
},
UserPlatformWipe { user_id: String, flags: i32 },
/// New emoji
EmojiCreate(Emoji),
/// Update existing emoji
EmojiUpdate {
id: String,
data: PartialEmoji,
},
/// Delete emoji
EmojiDelete {
id: String,
},
EmojiDelete { id: String },
/// New report
ReportCreate(Report),
@@ -277,33 +234,19 @@ pub enum EventV1 {
},
/// Delete channel
ChannelDelete {
id: String,
},
ChannelDelete { id: String },
/// User joins a group
ChannelGroupJoin {
id: String,
user: String,
},
ChannelGroupJoin { id: String, user: String },
/// User leaves a group
ChannelGroupLeave {
id: String,
user: String,
},
ChannelGroupLeave { id: String, user: String },
/// User started typing in a channel
ChannelStartTyping {
id: String,
user: String,
},
ChannelStartTyping { id: String, user: String },
/// User stopped typing in a channel
ChannelStopTyping {
id: String,
user: String,
},
ChannelStopTyping { id: String, user: String },
/// User acknowledged message in channel
ChannelAck {
@@ -323,56 +266,10 @@ pub enum EventV1 {
},
/// Delete webhook
WebhookDelete {
id: String,
},
WebhookDelete { id: String },
/// Auth events
CreateAccount {
account: Account,
},
CreateSession {
session: Session,
},
DeleteSession {
user_id: String,
session_id: String,
},
DeleteAllSessions {
user_id: String,
exclude_session_id: Option<String>,
},
/// Voice events
VoiceChannelJoin {
id: String,
state: UserVoiceState,
},
VoiceChannelLeave {
id: String,
user: String,
},
VoiceChannelMove {
user: String,
from: String,
to: String,
state: UserVoiceState,
},
UserVoiceStateUpdate {
id: String,
channel_id: String,
data: PartialUserVoiceState,
},
UserMoveVoiceChannel {
node: String,
from: String,
to: String,
token: String,
},
/// User's active slowmodes
UserSlowmodes {
slowmodes: Vec<ChannelSlowmode>,
},
Auth(AuthifierEvent),
}
impl EventV1 {

View File

@@ -37,20 +37,6 @@ pub struct GenericPayload {
pub user: User,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct DmCallPayload {
pub initiator_id: String,
pub channel_id: String,
pub started_at: Option<String>,
pub ended: bool,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct InternalDmCallPayload {
pub payload: DmCallPayload,
pub recipients: Option<Vec<String>>,
}
#[derive(Serialize, Deserialize)]
#[serde(tag = "type", content = "data")]
#[allow(clippy::large_enum_variant)]
@@ -60,7 +46,6 @@ pub enum PayloadKind {
FRReceived(FRReceivedPayload),
BadgeUpdate(usize),
Generic(GenericPayload),
DmCallStartEnd(DmCallPayload),
}
#[derive(Serialize, Deserialize)]
@@ -78,11 +63,3 @@ pub struct AckPayload {
pub channel_id: String,
pub message_id: String,
}
/// This is not the same as the AckPayload above, as the state for this event is stored in redis to allow for state updates while the event is queued.
#[derive(Serialize, Deserialize, Debug)]
pub struct AckEventPayload {
pub user_id: String,
pub channel_id: Option<String>,
pub server_id: Option<String>,
}

View File

@@ -25,8 +25,8 @@ pub use mongodb;
#[macro_use]
extern crate bson;
#[cfg(not(feature = "tokio-runtime"))]
compile_error!("tokio-runtime feature must be enabled.");
#[cfg(not(feature = "async-std-runtime"))]
compile_error!("async-std-runtime feature must be enabled.");
#[macro_export]
#[cfg(debug_assertions)]
@@ -112,10 +112,6 @@ pub mod tasks;
mod amqp;
pub use amqp::amqp::AMQP;
#[cfg(feature = "voice")]
pub mod voice;
/// Utility function to check if a boolean value is false
pub fn if_false(t: &bool) -> bool {
!t

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -98,18 +98,6 @@ pub async fn create_database(db: &MongoDb) {
.await
.expect("Failed to create pubsub collection.");
db.create_collection("sessions")
.await
.expect("Failed to create sessions collection.");
db.create_collection("account_invites")
.await
.expect("Failed to create account_invites collection.");
db.create_collection("mfa_tickets")
.await
.expect("Failed to create mfa_tickets collection.");
db.run_command(doc! {
"createIndexes": "users",
"indexes": [
@@ -275,89 +263,5 @@ pub async fn create_database(db: &MongoDb) {
.await
.expect("Failed to create ratelimit_events index.");
db.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"email": 1
},
"name": "email",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"email_normalised": 1
},
"name": "email_normalised",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"verification.token": 1
},
"name": "email_verification"
},
{
"key": {
"password_reset.token": 1
},
"name": "password_reset"
},
{
"key": {
"deletion.token": 1
},
"name": "account_deletion"
}
]
})
.await
.unwrap();
db.run_command(doc! {
"createIndexes": "sessions",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
},
{
"key": {
"user_id": 1
},
"name": "user_id"
}
]
})
.await
.unwrap();
db.run_command(doc! {
"createIndexes": "mfa_tickets",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
}
]
})
.await
.unwrap();
info!("Created database.");
}

View File

@@ -9,15 +9,15 @@ use crate::{
bson::{doc, from_bson, from_document, to_document, Bson, DateTime, Document},
options::FindOptions,
},
AbstractServers, Invite, MongoDb, User, DISCRIMINATOR_SEARCH_SPACE,
AbstractChannels, AbstractServers, Channel, Invite, MongoDb, User, DISCRIMINATOR_SEARCH_SPACE,
};
use bson::{oid::ObjectId, to_bson};
use futures::StreamExt;
use iso8601_timestamp::Timestamp;
use rand::seq::SliceRandom;
use revolt_permissions::{ChannelPermission, DEFAULT_WEBHOOK_PERMISSIONS};
use revolt_permissions::DEFAULT_WEBHOOK_PERMISSIONS;
use revolt_result::{Error, ErrorType};
use serde::{Deserialize, Serialize};
use ulid::Ulid;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Serialize, Deserialize)]
@@ -26,7 +26,7 @@ struct MigrationInfo {
revision: i32,
}
pub const LATEST_REVISION: i32 = 51; // MUST BE +1 to last migration
pub const LATEST_REVISION: i32 = 42; // MUST BE +1 to last migration
pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations");
@@ -452,7 +452,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
warn!("This is a destructive operation and will wipe existing permission data (excl. defaults for SendMessage).");
warn!("Taking a backup is advised.");
warn!("Continuing in 10 seconds...");
tokio::time::sleep(Duration::from_secs(10)).await;
async_std::task::sleep(Duration::from_secs(10)).await;
let servers = db.col::<Document>("servers");
let mut cursor = servers.find(doc! {}).await.unwrap();
@@ -574,145 +574,20 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
if revision <= 15 {
info!("Running migration [revision 15 / 04-06-2022]: Migrate Authifier to latest version.");
if !db
.db()
.collection::<Document>("mfa_tickets")
.list_index_names()
let db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db()));
db.run_migration(authifier::Migration::M2022_06_03EnsureUpToSpec)
.await
.unwrap_or_default()
.contains(&"token".to_owned())
{
// Make sure all collections exist
let list = db.db().list_collection_names().await.unwrap();
let collections = ["accounts", "sessions", "invites", "mfa_tickets"];
for name in collections {
if !list.contains(&name.to_string()) {
db.db().create_collection(name).await.unwrap();
}
}
// Setup index for `accounts`
let col = db.db().collection::<Document>("accounts");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"email": 1
},
"name": "email",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"email_normalised": 1
},
"name": "email_normalised",
"unique": true,
"collation": {
"locale": "en",
"strength": 2
}
},
{
"key": {
"verification.token": 1
},
"name": "email_verification"
},
{
"key": {
"password_reset.token": 1
},
"name": "password_reset"
}
]
})
.await
.unwrap();
// Setup index for `sessions`
let col = db.db().collection::<Document>("sessions");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "sessions",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
},
{
"key": {
"user_id": 1
},
"name": "user_id"
}
]
})
.await
.unwrap();
// Setup index for `mfa_tickets`
let col = db.db().collection::<Document>("mfa_tickets");
col.drop_indexes().await.unwrap();
db.db()
.run_command(doc! {
"createIndexes": "mfa_tickets",
"indexes": [
{
"key": {
"token": 1
},
"name": "token",
"unique": true
}
]
})
.await
.unwrap();
}
.unwrap();
}
if revision <= 16 {
info!("Running migration [revision 16 / 07-07-2022]: Add `emojis` collection and Authifier migration.");
if !db
.db()
.collection::<Document>("accounts")
.list_index_names()
let authifier_db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db()));
authifier_db
.run_migration(authifier::Migration::M2022_06_09AddIndexForDeletion)
.await
.expect("list of index names")
.contains(&"account_deletion".to_owned())
{
db.db()
.run_command(doc! {
"createIndexes": "accounts",
"indexes": [
{
"key": {
"deletion.token": 1
},
"name": "account_deletion"
}
]
})
.await
.unwrap();
}
.unwrap();
db.db()
.create_collection("emojis")
@@ -1039,7 +914,6 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
}
if revision <= 26 {
// 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!(
@@ -1206,14 +1080,6 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
channel_id: String,
}
#[allow(clippy::enum_variant_names)]
#[derive(serde::Serialize, serde::Deserialize)]
enum Channel {
Group { owner: String },
TextChannel { server: String },
VoiceChannel { server: String },
}
let webhooks = db
.db()
.collection::<WebhookShell>("channel_webhooks")
@@ -1225,13 +1091,8 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.await;
for webhook in webhooks {
match db
.col::<Channel>("channels")
.find_one(doc! { "_id": &webhook.channel_id })
.await
.unwrap()
{
Some(channel) => {
match db.fetch_channel(&webhook.channel_id).await {
Ok(channel) => {
let creator_id = match channel {
Channel::Group { owner, .. } => owner,
Channel::TextChannel { server, .. }
@@ -1239,6 +1100,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
let server = db.fetch_server(&server).await.expect("server");
server.owner
}
_ => unreachable!("not server or group channel!"),
};
db.db()
@@ -1256,13 +1118,17 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.await
.expect("update webhook");
}
None => {
Err(Error {
error_type: ErrorType::NotFound,
..
}) => {
db.db()
.collection::<WebhookShell>("channel_webhooks")
.delete_one(doc! { "_id": webhook._id })
.await
.expect("failed to delete invalid webhook");
}
Err(err) => panic!("{err:?}"),
}
}
}
@@ -1272,51 +1138,10 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
"Running migration [revision 32 / 12-05-2025]: (Authifier) Add last_seen to sessions."
);
loop {
#[derive(Deserialize)]
struct SessionId {
_id: String,
}
let sessions: Vec<SessionId> = db
.db()
.collection("sessions")
.find(doc! {
"$or": [
{ "last_seen": { "$exists": false } },
{ "last_seen": "1970-01-01T00:00:00.000Z" }
]
})
.limit(50_000) // about 400 batches for 2 million
.await
.expect("Failed to create cursor for sessions!")
.map(|doc| doc.expect("id and username"))
.collect()
.await;
if sessions.is_empty() {
break;
}
for session in sessions {
let timestamp = iso8601_timestamp::Timestamp::from(Ulid::from_string(&session._id).unwrap().datetime());
db.db()
.collection::<Document>("sessions")
.update_one(
doc! {
"_id": &session._id.to_string(),
},
doc! {
"$set": {
"last_seen": timestamp.format().to_string()
}
},
)
.await
.expect("Failed to update a session.");
}
}
let db = authifier::Database::MongoDb(authifier::database::MongoDb(db.db()));
db.run_migration(authifier::Migration::M2025_02_20AddLastSeenToSession)
.await
.unwrap();
}
if revision <= 40 {
@@ -1344,9 +1169,9 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.expect("failed to update users");
}
if revision <= 43 {
if revision <= 41 {
info!(
"Running migration [revision 43 / 05-06-2025]: convert role ranks to uniform numbers."
"Running migration [revision 41 / 05-06-2025]: convert role ranks to uniform numbers."
);
#[derive(Serialize, Deserialize, Clone)]
@@ -1401,95 +1226,6 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
}
}
if revision <= 46 {
info!("Running migration [revision 46 / 29-04-2025]: Convert all `VoiceChannel`'s into `TextChannel`");
db.col::<Document>("channels")
.update_many(
doc! { "channel_type": "VoiceChannel" },
doc! {
"$set": {
"channel_type": "TextChannel",
"voice": {}
}
},
)
.await
.expect("Failed to update voice channels");
};
if revision <= 48 {
info!("Running migration [revision 48 / 22-10-2025]: Add Video + Listen to default permissions");
db.col::<Document>("servers")
.update_many(
doc! { },
doc! {
"$bit": {
"default_permissions": {
"or": (ChannelPermission::Video + ChannelPermission::Speak + ChannelPermission::Listen) as i64
},
}
}
)
.await
.expect("Failed to update default_permissions");
};
if revision <= 49 {
info!("Running migration [revision 49 / 12-12-2025]: Add _id key to roles");
#[derive(Serialize, Deserialize, Clone)]
struct Server {
#[serde(rename = "_id")]
pub id: String,
#[serde(default = "HashMap::<String, Document>::new")]
pub roles: HashMap<String, Document>,
}
let mut servers = db
.db()
.collection::<Server>("servers")
.find(doc! {
"roles": {
"$exists": true,
"$ne": {}
}
})
.await
.unwrap()
.map(|res| res.expect("Failed to decode Server { id, roles }"));
while let Some(server) = servers.next().await {
let mut doc = doc! {};
for id in server.roles.keys() {
doc.insert(format!("roles.{id}._id"), id);
}
db.db()
.collection::<Server>("servers")
.update_one(doc! { "_id": &server.id }, doc! { "$set": doc })
.await
.unwrap();
}
};
if revision <= 50 {
info!("Running migration [revision 50 / 13-04-2026]: Rename invites collection to account_invites");
db.db()
.client()
.database("admin")
.run_command(doc! {
"renameCollection": "revolt.invites",
"to": "revolt.account_invites",
"dropTarget": true
})
.await
.unwrap();
}
// Reminder to update LATEST_REVISION when adding new migrations.
LATEST_REVISION.max(revision)
}

View File

@@ -0,0 +1,30 @@
use iso8601_timestamp::Timestamp;
use crate::OAuth2Scope;
auto_derived! (
/// Unique id of the user and bot
#[derive(Hash)]
pub struct AuthorizedBotId {
/// User id
pub user: String,
/// Bot Id
pub bot: String,
}
pub struct AuthorizedBot {
/// Unique Id
#[serde(rename = "_id")]
pub id: AuthorizedBotId,
/// When the authorized oauth2 bot connection was created at
pub created_at: Timestamp,
/// If and when the authorized oauth2 bot connection was revoked at
pub deauthorized_at: Option<Timestamp>,
/// Scopes the bot has access to
pub scope: Vec<OAuth2Scope>,
}
);

View File

@@ -0,0 +1,27 @@
use revolt_result::Result;
use crate::{AuthorizedBot, AuthorizedBotId};
mod mongodb;
mod reference;
#[async_trait]
pub trait AbstractAuthorizedBots: Sync + Send {
/// Insert emoji into database.
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()>;
/// Fetch an authorized bot by its id
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
/// Fetch a users authorized bot by its id
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>>;
/// Deletes an authorized bot
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()>;
/// Deauthorizes an authorized bot
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot>;
// Fetches all authorized bots which have been deauthorized
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>>;
}

View File

@@ -0,0 +1,71 @@
use bson::to_bson;
use revolt_result::Result;
use iso8601_timestamp::Timestamp;
use crate::{MongoDb, AuthorizedBot, AuthorizedBotId};
use super::AbstractAuthorizedBots;
static COL: &str = "authorized_bots";
#[async_trait]
impl AbstractAuthorizedBots for MongoDb {
/// Insert an authorized bot into database.
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
query!(self, insert_one, COL, &authorized_bot).map(|_| ())
}
/// Fetch an authorized bot by its id
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
query!(
self,
find_one,
COL,
doc! {
"_id.user": &id.user,
"_id.bot": &id.bot
}
)?.ok_or_else(|| create_error!(NotFound))
}
/// Fetch a users authorized bot by its id
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
query!(self, find, COL, doc! { "_id.user": &user_id })
}
/// Deletes an authorized bot
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
query!(self, delete_one, COL, doc! { "_id.user": &id.user, "_id.bot": &id.bot }).map(|_| ())
}
/// Deauthorizes an authorized bot
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
self.col::<AuthorizedBot>(COL)
.find_one_and_update(
doc! {
"_id.user": &id.user,
"_id.bot": &id.bot
},
doc! {
"$set": {
"deauthorized_at": to_bson(&Timestamp::now_utc()).unwrap()
}
}
)
.await
.map_err(|_| create_database_error!("find_one_and_update", COL))
.and_then(|opt| opt.ok_or_else(|| create_database_error!("find_one_and_update", COL)))
}
// Fetches all authorized bots which have been deauthorized
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
query!(
self,
find,
COL,
doc! {
"deauthorized_at": { "$exists": true }
}
)
}
}

View File

@@ -0,0 +1,76 @@
use revolt_result::Result;
use iso8601_timestamp::Timestamp;
use crate::{ReferenceDb, AuthorizedBot, AuthorizedBotId};
use super::AbstractAuthorizedBots;
#[async_trait]
impl AbstractAuthorizedBots for ReferenceDb {
/// Insert an authorized bot into database.
async fn insert_authorized_bot(&self, authorized_bot: &AuthorizedBot) -> Result<()> {
let mut authorized_bots = self.authorized_bots.lock().await;
if authorized_bots.contains_key(&authorized_bot.id) {
Err(create_database_error!("insert", "authorized_bots"))
} else {
authorized_bots.insert(authorized_bot.id.clone(), authorized_bot.clone());
Ok(())
}
}
/// Fetch an authorized bot by its id
async fn fetch_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
let authorized_bots = self.authorized_bots.lock().await;
authorized_bots.get(id).cloned().ok_or_else(|| create_error!(NotFound))
}
/// Fetch a users authorized bot by its id
async fn fetch_users_authorized_bots(&self, user_id: &str) -> Result<Vec<AuthorizedBot>> {
let authorized_bots = self.authorized_bots.lock().await;
Ok(authorized_bots
.values()
.filter(|authorized_bot| authorized_bot.id.user == user_id)
.cloned()
.collect()
)
}
/// Deletes an authorized bot
async fn delete_authorized_bot(&self, id: &AuthorizedBotId) -> Result<()> {
let mut authorized_bots = self.authorized_bots.lock().await;
if authorized_bots.remove(id).is_some() {
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Deauthorizes an authorized bot
async fn deauthorize_authorized_bot(&self, id: &AuthorizedBotId) -> Result<AuthorizedBot> {
let mut authorized_bots = self.authorized_bots.lock().await;
if let Some(authorized_bot) = authorized_bots.get_mut(id) {
authorized_bot.deauthorized_at = Some(Timestamp::now_utc());
Ok(authorized_bot.clone())
} else {
Err(create_error!(NotFound))
}
}
// Fetches all authorized bots which have been deauthorized
async fn fetch_deauthorized_authorized_bots(&self) -> Result<Vec<AuthorizedBot>> {
let authorized_bots = self.authorized_bots.lock().await;
Ok(authorized_bots
.values()
.filter(|authorized_bot| authorized_bot.deauthorized_at.is_some())
.cloned()
.collect()
)
}
}

View File

@@ -1,5 +1,6 @@
use revolt_result::Result;
use ulid::Ulid;
use std::collections::HashMap;
use crate::{events::client::EventV1, BotInformation, Database, PartialUser, User};
@@ -35,6 +36,10 @@ auto_derived_partial!(
#[serde(skip_serializing_if = "String::is_empty", default)]
pub privacy_policy_url: String,
/// Oauth2 bot settings
#[serde(skip_serializing_if = "Option::is_none", default)]
pub oauth2: Option<BotOauth2>,
/// Enum of bot flags
#[serde(skip_serializing_if = "Option::is_none")]
pub flags: Option<i32>,
@@ -42,11 +47,52 @@ auto_derived_partial!(
"PartialBot"
);
auto_derived!(
#[derive(Copy, Hash)]
pub enum OAuth2Scope {
#[serde(rename = "read:identify")]
ReadIdentify,
#[serde(rename = "read:servers")]
ReadServers,
#[serde(rename = "write:files")]
WriteFiles,
#[serde(rename = "events")]
Events,
#[serde(rename = "full")]
Full,
}
pub struct OAuth2ScopeReasoning {
pub allow: String,
pub deny: String
}
);
auto_derived_partial!(
pub struct BotOauth2 {
/// Whether the oauth2 client is public and should not receive a secret key
#[serde(default)]
pub public: bool,
/// Secret key used for authorisation, not provided if the client is public
#[serde(default)]
pub secret: Option<String>,
/// Allowed redirects for the authorisation
#[serde(default)]
pub redirects: Vec<String>,
/// Mapping of allowed scopes and the reasonings
#[serde(default)]
pub allowed_scopes: HashMap<OAuth2Scope, OAuth2ScopeReasoning>,
},
"PartialBotOauth2"
);
auto_derived!(
/// Optional fields on bot object
pub enum FieldsBot {
Token,
InteractionsURL,
Oauth2,
Oauth2Secret,
}
);
@@ -63,11 +109,24 @@ impl Default for Bot {
interactions_url: Default::default(),
terms_of_service_url: Default::default(),
privacy_policy_url: Default::default(),
oauth2: Default::default(),
flags: Default::default(),
}
}
}
#[allow(clippy::derivable_impls)]
impl Default for BotOauth2 {
fn default() -> Self {
Self {
public: false,
secret: Some(nanoid::nanoid!(64)),
redirects: Vec::new(),
allowed_scopes: HashMap::new(),
}
}
}
#[allow(clippy::disallowed_methods)]
impl Bot {
/// Create a new bot
@@ -124,6 +183,14 @@ impl Bot {
FieldsBot::Token => self.token = nanoid::nanoid!(64),
FieldsBot::InteractionsURL => {
self.interactions_url = String::new();
},
FieldsBot::Oauth2 => self.oauth2 = None,
FieldsBot::Oauth2Secret => {
if let Some(oauth2) = &mut self.oauth2 {
if !oauth2.public {
oauth2.secret = Some(nanoid::nanoid!(64))
}
}
}
}
}
@@ -164,7 +231,7 @@ impl Bot {
mod tests {
use crate::{Bot, FieldsBot, PartialBot, User};
#[tokio::test]
#[async_std::test]
async fn crud() {
database_test!(|db| async move {
let owner = User::create(&db, "Owner".to_string(), None, None)

View File

@@ -87,6 +87,8 @@ impl IntoDocumentPath for FieldsBot {
match self {
FieldsBot::InteractionsURL => Some("interactions_url"),
FieldsBot::Token => None,
FieldsBot::Oauth2 => Some("oauth2"),
FieldsBot::Oauth2Secret => None,
}
}
}

View File

@@ -69,7 +69,7 @@ impl Invite {
creator: creator.id.clone(),
channel: id.clone(),
}),
Channel::TextChannel { id, server, .. } => {
Channel::TextChannel { id, server, .. } | Channel::VoiceChannel { id, server, .. } => {
Ok(Invite::Server {
code,
creator: creator.id.clone(),

View File

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

View File

@@ -1,7 +1,5 @@
#![allow(deprecated)]
use std::{borrow::Cow, collections::HashMap};
use std::collections::HashMap;
use redis_kiss::get_connection;
use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor};
use revolt_permissions::OverrideField;
@@ -10,7 +8,8 @@ use serde::{Deserialize, Serialize};
use ulid::Ulid;
use crate::{
events::client::EventV1, Database, File, PartialServer, Server, SystemMessage, User, AMQP,
events::client::EventV1, Database, File, PartialServer,
Server, SystemMessage, User, AMQP,
};
#[cfg(feature = "mongodb")]
@@ -107,22 +106,38 @@ auto_derived!(
/// Whether this channel is marked as not safe for work
#[serde(skip_serializing_if = "crate::if_false", default)]
nsfw: bool,
/// Voice Information for when this channel is also a voice channel
#[serde(skip_serializing_if = "Option::is_none")]
voice: Option<VoiceInformation>,
/// The channel's slowmode delay in seconds
#[serde(skip_serializing_if = "Option::is_none")]
slowmode: Option<u64>,
},
}
/// Voice channel belonging to a server
VoiceChannel {
/// Unique Id
#[serde(rename = "_id")]
id: String,
/// Id of the server this channel belongs to
server: String,
#[derive(Default)]
pub struct VoiceInformation {
/// Maximium amount of users allowed in the voice channel at once
#[serde(skip_serializing_if = "Option::is_none")]
pub max_users: Option<usize>,
/// Display name of the channel
name: String,
#[serde(skip_serializing_if = "Option::is_none")]
/// Channel description
description: Option<String>,
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
icon: Option<File>,
/// Default permissions assigned to users in this channel
#[serde(skip_serializing_if = "Option::is_none")]
default_permissions: Option<OverrideField>,
/// Permissions assigned based on role to this channel
#[serde(
default = "HashMap::<String, OverrideField>::new",
skip_serializing_if = "HashMap::<String, OverrideField>::is_empty"
)]
role_permissions: HashMap<String, OverrideField>,
/// Whether this channel is marked as not safe for work
#[serde(skip_serializing_if = "crate::if_false", default)]
nsfw: bool,
},
}
);
@@ -149,10 +164,6 @@ auto_derived!(
pub default_permissions: Option<OverrideField>,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_message_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice: Option<VoiceInformation>,
#[serde(skip_serializing_if = "Option::is_none")]
pub slowmode: Option<u64>,
}
/// Optional fields on channel object
@@ -160,8 +171,6 @@ auto_derived!(
Description,
Icon,
DefaultPermissions,
Voice,
Slowmode,
}
);
@@ -213,21 +222,16 @@ impl Channel {
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()),
slowmode: None,
},
v0::LegacyServerChannelType::Voice => Channel::TextChannel {
v0::LegacyServerChannelType::Voice => Channel::VoiceChannel {
id: id.clone(),
server: server.id.to_owned(),
name: data.name,
description: data.description,
icon: None,
last_message_id: None,
default_permissions: None,
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()),
slowmode: None,
},
};
@@ -409,10 +413,7 @@ impl Channel {
/// Check whether has a user as a recipient
pub fn contains_user(&self, user_id: &str) -> bool {
match self {
Channel::Group { recipients, .. } | Channel::DirectMessage { recipients, .. } => {
recipients.iter().any(|recipient| recipient == user_id)
}
Channel::SavedMessages { user, .. } => user == user_id,
Channel::Group { recipients, .. } => recipients.contains(&String::from(user_id)),
_ => false,
}
}
@@ -420,9 +421,7 @@ impl Channel {
/// Get list of recipients
pub fn users(&self) -> Result<Vec<String>> {
match self {
Channel::Group { recipients, .. } | Channel::DirectMessage { recipients, .. } => {
Ok(recipients.to_owned())
}
Channel::Group { recipients, .. } => Ok(recipients.to_owned()),
_ => Err(create_error!(NotFound)),
}
}
@@ -433,28 +432,8 @@ impl Channel {
Channel::DirectMessage { id, .. }
| Channel::Group { id, .. }
| Channel::SavedMessages { id, .. }
| Channel::TextChannel { id, .. } => id,
}
}
/// Clone this channel's server id
pub fn server(&self) -> Option<&str> {
match self {
Channel::TextChannel { server, .. } => Some(server),
_ => None,
}
}
/// Gets this channel's voice information
pub fn voice(&self) -> Option<Cow<VoiceInformation>> {
match self {
Self::DirectMessage { .. } | Self::Group { .. } => {
Some(Cow::Owned(VoiceInformation::default()))
}
Self::TextChannel {
voice: Some(voice), ..
} => Some(Cow::Borrowed(voice)),
_ => None,
| Channel::TextChannel { id, .. }
| Channel::VoiceChannel { id, .. } => id,
}
}
@@ -471,6 +450,12 @@ impl Channel {
server,
role_permissions,
..
}
| Channel::VoiceChannel {
id,
server,
role_permissions,
..
} => {
db.set_channel_role_permission(id, role_id, permissions)
.await?;
@@ -517,7 +502,7 @@ impl Channel {
clear: remove.into_iter().map(|v| v.into()).collect(),
}
.p(match self {
Self::TextChannel { server, .. } => server.clone(),
Self::TextChannel { server, .. } | Self::VoiceChannel { server, .. } => server.clone(),
_ => id,
})
.await;
@@ -529,13 +514,17 @@ impl Channel {
pub fn remove_field(&mut self, field: &FieldsChannel) {
match field {
FieldsChannel::Description => match self {
Self::Group { description, .. } | Self::TextChannel { description, .. } => {
Self::Group { description, .. }
| Self::TextChannel { description, .. }
| Self::VoiceChannel { description, .. } => {
description.take();
}
_ => {}
},
FieldsChannel::Icon => match self {
Self::Group { icon, .. } | Self::TextChannel { icon, .. } => {
Self::Group { icon, .. }
| Self::TextChannel { icon, .. }
| Self::VoiceChannel { icon, .. } => {
icon.take();
}
_ => {}
@@ -544,23 +533,15 @@ impl Channel {
Self::TextChannel {
default_permissions,
..
}
| Self::VoiceChannel {
default_permissions,
..
} => {
default_permissions.take();
}
_ => {}
},
FieldsChannel::Voice => match self {
Self::TextChannel { voice, .. } => {
voice.take();
}
_ => {}
},
FieldsChannel::Slowmode => match self {
Self::TextChannel { slowmode, .. } => {
slowmode.take();
}
_ => {}
}
}
}
@@ -572,7 +553,6 @@ impl Channel {
}
/// Apply partial channel to channel
#[allow(deprecated)]
pub fn apply_options(&mut self, partial: PartialChannel) {
match self {
Self::SavedMessages { .. } => {}
@@ -621,7 +601,15 @@ impl Channel {
nsfw,
default_permissions,
role_permissions,
voice,
..
}
| Self::VoiceChannel {
name,
description,
icon,
nsfw,
default_permissions,
role_permissions,
..
} => {
if let Some(v) = partial.name {
@@ -647,16 +635,12 @@ impl Channel {
if let Some(v) = partial.default_permissions {
default_permissions.replace(v);
}
if let Some(v) = partial.voice {
voice.replace(v);
}
}
}
}
/// Acknowledge a message
pub async fn ack(&self, user: &str, message: &str, amqp: &AMQP) -> Result<()> {
pub async fn ack(&self, user: &str, message: &str) -> Result<()> {
EventV1::ChannelAck {
id: self.id().to_string(),
user: user.to_string(),
@@ -665,7 +649,17 @@ impl Channel {
.private(user.to_string())
.await;
crate::util::acker::ack_channel(user, self.id(), message, amqp).await
#[cfg(feature = "tasks")]
crate::tasks::ack::queue_ack(
self.id().to_string(),
user.to_string(),
crate::tasks::ack::AckEvent::AckMessage {
id: message.to_string(),
},
)
.await;
Ok(())
}
/// Remove user from a group
@@ -783,8 +777,6 @@ impl IntoDocumentPath for FieldsChannel {
FieldsChannel::Description => "description",
FieldsChannel::Icon => "icon",
FieldsChannel::DefaultPermissions => "default_permissions",
FieldsChannel::Voice => "voice",
FieldsChannel::Slowmode => "slowmode",
})
}
}
@@ -795,7 +787,7 @@ mod tests {
use crate::{fixture, util::permissions::DatabasePermissionQuery};
#[tokio::test]
#[async_std::test]
async fn permissions_group_channel() {
database_test!(|db| async move {
fixture!(db, "group_with_members",
@@ -821,7 +813,7 @@ mod tests {
});
}
#[tokio::test]
#[async_std::test]
async fn permissions_text_channel() {
database_test!(|db| async move {
fixture!(db, "server_with_roles",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -45,8 +45,7 @@ auto_derived!(
Image {
width: isize,
height: isize,
thumbhash: Option<Vec<u8>>,
animated: Option<bool>,
// 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 },

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