Compare commits

..

2 Commits

Author SHA1 Message Date
Zomatree
1fc5711338 Merge branch 'main' into feat/sso 2025-11-18 21:38:43 +00:00
Zomatree
2bbd702ec2 feat: sso 2025-11-18 21:33:21 +00:00
208 changed files with 4921 additions and 12347 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
...

View File

@@ -7,20 +7,19 @@ on:
pull_request:
paths:
- "Dockerfile"
workflow_dispatch:
permissions:
contents: read
packages: write
concurrency:
group: ${{ github.head_ref || github.ref }}
concurrency:
group: ${{ github.head_ref }}
cancel-in-progress: true
jobs:
base:
name: Test base image build
runs-on: arc-runner-set
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
# Configure build environment
@@ -41,7 +40,7 @@ jobs:
cache-to: type=gha,scope=buildx-base-multi-arch,mode=max
publish:
runs-on: arc-runner-set
runs-on: self-hosted
if: github.event_name != 'pull_request'
name: Publish Docker images
steps:

View File

@@ -14,10 +14,16 @@ jobs:
- 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
- uses: pnpm/action-setup@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- run: mise docs:build
package_json_file: ./docs/package.json
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
cache-dependency-path: ./docs/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Test build website
run: pnpm run build

View File

@@ -16,13 +16,19 @@ jobs:
- 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
- uses: pnpm/action-setup@v4
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
- run: mise docs:build
package_json_file: ./docs/package.json
- uses: actions/setup-node@v4
with:
node-version: lts/*
cache: pnpm
cache-dependency-path: ./docs/pnpm-lock.yaml
- name: Install dependencies
run: pnpm install
- name: Build website
run: pnpm run build
- name: Upload Build Artifact
uses: actions/upload-pages-artifact@v3

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,24 +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
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

@@ -5,8 +5,8 @@ on:
branches: [main]
pull_request:
concurrency:
group: ${{ github.head_ref || github.ref }}
concurrency:
group: ${{ github.head_ref }}
cancel-in-progress: true
env:
@@ -15,54 +15,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: Free up disk space
run: |
sudo rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc
- 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
args: --locked
- 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
run: |
mise test
cargo nextest run
- name: MongoDB Test
- name: Run cargo test (with MongoDB)
env:
TEST_DB: MONGODB
MONGODB: mongodb://localhost
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
path: api
@@ -74,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"

View File

@@ -1,6 +1,3 @@
# DO NOT EDIT DIRECTLY IN REPOSITORY
# Managed in Terraform templates
name: "Lint PR"
on:
@@ -18,6 +15,6 @@ jobs:
permissions:
pull-requests: read
steps:
- uses: amannn/action-semantic-pull-request@48f256284bd46cdaab1048c3721360e808335d50 # v6.1.1
- uses: amannn/action-semantic-pull-request@v6
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,196 +0,0 @@
# Changelog
## [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))

4835
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,191 +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-std = "1.8.0"
async-tungstenite = "0.17.0"
futures-locks = "0.7.1"
async-lock = "2.8.0"
async-recursion = "1.0.4"
# Error Handling
anyhow = "1.0.100"
thiserror = "2.0.18"
sentry = "0.31.5"
sentry-anyhow = "0.38.1"
# Data Validation
regex = "1.12.3"
validator = "0.16"
# Data Types
uuid = "1.19.0"
ulid = "1.2.1"
nanoid = "0.4.0"
typenum = "1.17.0"
num_enum = "0.6.1"
bitfield = "0.13.2"
# Time
chrono = "0.4.15"
iso8601-timestamp = "0.2.10"
# Data Collections
lru = "0.16.3"
indexmap = "2.13.1"
dashmap = "5.2.0"
moka = "0.12.8"
lru_time_cache = "0.11.11"
deadqueue = "0.2.4"
# Web scraping
scraper = "0.20.0"
encoding_rs = "0.8.34"
# Mail
lettre = "0.10.0-alpha.4"
# HTTP Requests
reqwest = "0.13.2"
isahc = "1.7"
# Notifications
fcm_v1 = "0.3.0"
web-push = "0.10.0"
revolt_a2 = "0.10"
# Parsing
logos = "0.15"
# SVG rendering
usvg = "0.44.0"
resvg = "0.44.0"
tiny-skia = "0.11.4"
# Logging
log = "0.4.29"
pretty_env_logger = "0.4.0"
# Redis
redis-kiss = "0.1.4"
fred = "8.0.1"
# Serialisation
bincode = "1.3.3"
serde_json = "1.0.79"
rmp-serde = "1.0.0"
serde = { version = "1", features = ["derive"] }
strum_macros = "0.26.4"
# MongoDB
bson = { version = "2.1.0" }
mongodb = { version = "3.1.0" }
# S3
aws-config = "1.5.5"
aws-sdk-s3 = "1.46.0"
# Axum (HTTP server)
axum-macros = "0.4.1"
axum_typed_multipart = "0.12.1"
axum = "0.7.5"
axum-extra = "0.9"
tower-http = "0.5.2"
# Rocket (HTTP server)
rocket = "0.5.1"
rocket_empty = "0.1.1"
revolt_rocket_okapi = "0.10.0"
rocket_cors = { git = "https://github.com/lawliet89/rocket_cors", rev = "072d90359b23e9b291df6b672c07c93de9c46011" }
rocket_authifier = "1.0.16"
rocket_prometheus = "0.10.0-rc.3"
# Spec Generation
utoipa = "4.2.3"
revolt_okapi = "0.9.1"
schemars = "0.8.8"
utoipa-scalar = "0.1.0"
# Image Processing
jxl-oxide = "0.12.5"
sha2 = "0.10.8"
kamadak-exif = "0.5.4"
webp = "0.3.0"
image = "0.25.2" # avif encode requires dav1d system library: features = ["avif-native"]
thumbhash = "0.1.0"
lcms2 = "6.1.1" # for color profile processing
# File processing
revolt_clamav-client = "0.1.5"
simdutf8 = "0.1.4"
# Content type processing
infer = "0.16.0"
ffprobe = "0.4.0"
imagesize = "0.13.0"
# OpenTelemetry
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.22", features = [
"env-filter",
] } # consider https://crates.io/crates/better-tracing
opentelemetry = { version = "0.31.0", features = ["logs"] }
opentelemetry_sdk = { version = "0.31.0", features = ["logs"] }
opentelemetry-otlp = { version = "0.31.0", features = ["logs"] }
opentelemetry-appender-tracing = "0.31.1"
# Authifier
authifier = "1.0.16"
# RabbitMQ
amqprs = "1.7.0"
lapin = "4.7.1"
# Voice
livekit-api = "0.4.4"
livekit-protocol = "0.7.4"
livekit-runtime = "0.4.0"
# Other Utilities
once_cell = "1.9.0"
config = "0.13.3"
cached = "0.44.0"
rand = "0.8.5"
base64 = "0.21.3"
decancer = "3.3.3"
linkify = "0.8.1"
url-escape = "0.1.1"
revolt_optional_struct = "0.2.0"
unicode-segmentation = "1.10.1"
querystring = "1.1.0"
tempfile = "3.12.0"
aes-gcm = "0.10.3"
auto_ops = "0.3.0"
url = "2.2.2"
impl_ops = "0.1.1"
lazy_static = "1.5.0"
mime = "0.3.17"
futures-lite = "2.6.1"
# Build Dependencies
vergen = "7.5.0"
# Local packages
revolt-coalesced = { version = "0.12.0", path = "crates/core/coalesced" }
revolt-config = { version = "0.12.0", path = "crates/core/config" }
revolt-database = { version = "0.12.0", path = "crates/core/database" }
revolt-files = { version = "0.12.0", path = "crates/core/files" }
revolt-models = { version = "0.12.0", path = "crates/core/models" }
revolt-parser = { version = "0.12.0", path = "crates/core/parser" }
revolt-permissions = { version = "0.12.0", path = "crates/core/permissions" }
revolt-presence = { version = "0.12.0", path = "crates/core/presence" }
revolt-ratelimits = { version = "0.12.0", path = "crates/core/ratelimits" }
revolt-result = { version = "0.12.0", 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

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

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"
@@ -61,7 +61,7 @@ secret = "ZjCofRlfm6GGtjlifmNpCDkcQbEIIVC0"
# S3 protocol endpoint
endpoint = "http://127.0.0.1:14009"
# S3 region name
region = "us-east-1"
region = "minio"
# S3 protocol key ID
access_key_id = "minioautumn"
# S3 protocol access key

View File

@@ -8,29 +8,15 @@ 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
@@ -69,7 +55,7 @@ services:
# Mock SMTP server
maildev:
image: maildev/maildev
image: soulteary/maildev
ports:
- "14025:25"
- "14080:8080"
@@ -80,8 +66,8 @@ services:
MAILDEV_INCOMING_PASS: smtp
livekit:
image: ghcr.io/stoatchat/livekit-server:v1.9.13
image: ghcr.io/stoatchat/livekit-server:v1.9.9
command: --config /etc/livekit.yml
network_mode: "host"
volumes:
- ./livekit.yml:/etc/livekit.yml
- ./livekit.yml:/etc/livekit.yml

View File

@@ -1,46 +1,49 @@
[package]
name = "revolt-bonfire"
version = "0.12.1"
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 }
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 = ["async-std-runtime"] }
async-std = { workspace = true }
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
authifier = { workspace = true }
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", features = ["voice"] }
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

@@ -4,7 +4,7 @@ use futures::future::join_all;
use revolt_database::{
events::client::{EventV1, ReadyPayloadFields},
util::permissions::DatabasePermissionQuery,
voice::{get_channel_voice_state, UserVoiceChannel},
voice::get_channel_voice_state,
Channel, Database, Member, MemberCompositeKey, Presence, RelationshipStatus,
};
use revolt_models::v0;
@@ -167,13 +167,9 @@ impl State {
| Channel::TextChannel { voice: Some(_), .. }
)
}) {
if let Ok(Some(voice_state)) =
get_channel_voice_state(&UserVoiceChannel::from_channel(channel)).await
{
if let Ok(Some(voice_state)) = get_channel_voice_state(channel).await {
if let Some(server) = channel.server() {
let set = voice_state_server_members
.entry(server.to_string())
.or_default();
let set = voice_state_server_members.entry(server.to_string()).or_default();
for participant in &voice_state.participants {
user_ids.insert(participant.id.clone());
@@ -401,11 +397,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 {

View File

@@ -1,5 +1,7 @@
use std::{
collections::{HashMap, HashSet}, num::NonZeroUsize, sync::Arc, time::Duration
collections::{HashMap, HashSet},
sync::Arc,
time::Duration,
};
use async_std::sync::{Mutex, RwLock};
@@ -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

@@ -5,7 +5,7 @@ use authifier::AuthifierEvent;
use fred::{
error::RedisErrorKind,
interfaces::{ClientLike, EventInterface, PubsubInterface},
types::{ReconnectPolicy, RedisConfig},
types::RedisConfig,
};
use futures::{
channel::oneshot,
@@ -218,16 +218,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,
};
@@ -236,21 +230,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(())
});
@@ -433,8 +422,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();
@@ -471,10 +458,6 @@ async fn worker(
match payload {
ClientMessage::BeginTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
@@ -487,10 +470,6 @@ async fn worker(
.await;
}
ClientMessage::EndTyping { channel } => {
if revolt_config.disable_events_dont_use {
continue;
}
if !subscribed.read().await.contains(&channel) {
continue;
}
@@ -527,4 +506,4 @@ async fn worker(
}
}
}
}
}

View File

@@ -1,11 +1,10 @@
[package]
name = "revolt-coalesced"
version = "0.12.1"
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.12.1"
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
@@ -18,24 +17,27 @@ default = ["test", "sentry"]
[dependencies]
# Utility
config = { workspace = true }
cached = { workspace = true }
once_cell = { 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 }
async-std = { workspace = true, features = ["attributes"], 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 }
# Authifier
authifier = "1.0.15"

View File

@@ -1,5 +1,4 @@
production = false
disable_events_dont_use = false
[database]
# MongoDB connection URL
@@ -12,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"
@@ -30,16 +29,12 @@ host = "rabbit"
port = 5672
username = "rabbituser"
password = "rabbitpass"
default_exchange = "revolt"
[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]
@@ -79,11 +74,11 @@ max_concurrent_connections = 50
# How long to ring devices for when calling in dms/groups, in seconds
call_ring_duration = 30
[api.sso]
[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
@@ -95,11 +90,6 @@ 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"
@@ -136,8 +126,6 @@ pkcs8 = ""
key_id = ""
team_id = ""
[january]
blocked_domains = []
[files]
# Encryption key for stored files
@@ -234,10 +222,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
@@ -262,7 +246,7 @@ 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
# Mamimum resolution (width, height) of video streams in voice calls
video_resolution = [1080, 720]
# Minimum and maximum aspect ratio of video streams in voice calls
@@ -301,8 +285,8 @@ 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]
# Mamimum 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]
@@ -321,12 +305,6 @@ 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 = ""
@@ -336,4 +314,4 @@ files = ""
proxy = ""
pushd = ""
crond = ""
gifbox = ""
gifbox = ""

View File

@@ -1,10 +1,11 @@
use std::{collections::HashMap, path::Path};
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;
use authifier::config::SSO;
#[cfg(feature = "sentry")]
pub use sentry::{capture_error, capture_message, Level};
@@ -94,23 +95,12 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::new(|| {
}
}
let cwd = std::env::current_dir().unwrap();
let mut cwd: Option<&Path> = Some(&cwd);
while let Some(path) = cwd {
for config_path in CONFIG_SEARCH_PATHS {
let config_path = path.join(config_path);
if config_path.exists() {
builder = builder
.add_source(File::new(config_path.to_str().unwrap(), FileFormat::Toml));
}
for path in CONFIG_SEARCH_PATHS {
if std::path::Path::new(path).exists() {
builder = builder.add_source(File::new(path, FileFormat::Toml));
}
cwd = path.parent();
}
builder = builder.add_source(Environment::with_prefix("REVOLT").separator("__"));
builder.build().unwrap()
})
});
@@ -119,12 +109,6 @@ static CONFIG_BUILDER: Lazy<RwLock<Config>> = Lazy::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)]
@@ -133,8 +117,6 @@ pub struct Rabbit {
pub port: u16,
pub username: String,
pub password: String,
pub default_exchange: String,
pub queues: RabbitQueues,
}
#[derive(Deserialize, Debug, Clone)]
@@ -238,7 +220,6 @@ pub struct LiveKitNode {
#[derive(Deserialize, Debug, Clone)]
pub struct ApiUsers {
pub early_adopter_cutoff: Option<u64>,
pub min_username_length: usize,
}
#[derive(Deserialize, Debug, Clone)]
@@ -249,6 +230,7 @@ pub struct Api {
pub workers: ApiWorkers,
pub livekit: ApiLiveKit,
pub users: ApiUsers,
pub sso: SSO,
}
#[derive(Deserialize, Debug, Clone)]
@@ -256,7 +238,6 @@ 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,
@@ -309,11 +290,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,
@@ -358,8 +334,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,16 +363,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)]
@@ -416,7 +380,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,
@@ -444,12 +407,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 disable_events_dont_use: bool,
}
impl Settings {
@@ -480,14 +441,12 @@ 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;
}

View File

@@ -1,11 +1,10 @@
[package]
name = "revolt-database"
version = "0.12.1"
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
@@ -16,14 +15,8 @@ mongodb = ["dep:mongodb", "bson", "authifier/database-mongodb"]
# ... Other
tasks = ["isahc", "linkify", "url-escape"]
async-std-runtime = ["async-std", "authifier/async-std-runtime"]
rocket-impl = [
"rocket",
"schemars",
"revolt_okapi",
"revolt_rocket_okapi",
"authifier/rocket_impl",
]
axum-impl = ["axum", "revolt-result/axum"]
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"]
@@ -32,72 +25,80 @@ 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 }
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"] }
# 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 }
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
async-std = { workspace = true, features = ["attributes"], optional = true }
async-std = { version = "1.8.0", features = ["attributes"], optional = true }
# Axum Impl
axum = { workspace = true, 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 = { workspace = true }
authifier = { version = "1.0.15" }
# RabbitMQ
amqprs = { workspace = true }
amqprs = { version = "1.7.0" }
# Voice
livekit-api = { workspace = true, optional = true }
livekit-protocol = { workspace = true, optional = true }
livekit-runtime = { workspace = true, features = ["tokio"], optional = true }
livekit-api = { version = "0.4.4", optional = true}
livekit-protocol = { version = "0.4.0", optional = true }
livekit-runtime = { version = "0.3.1", features = ["tokio"], optional = true }

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

@@ -2,10 +2,7 @@ use std::collections::HashSet;
use crate::events::rabbit::*;
use crate::User;
use amqprs::channel::{
BasicPublishArguments, ExchangeDeclareArguments, ExchangeType, QueueBindArguments,
QueueDeclareArguments,
};
use amqprs::channel::{BasicPublishArguments, ExchangeDeclareArguments};
use amqprs::connection::OpenConnectionArguments;
use amqprs::{channel::Channel, connection::Connection, error::Error as AMQPError};
use amqprs::{BasicProperties, FieldTable};
@@ -58,43 +55,6 @@ impl AMQP {
AMQP::new(connection, channel)
}
pub async fn configure_channels(&self) -> revolt_result::Result<()> {
let config = revolt_config::config().await;
self.channel
.exchange_declare(
ExchangeDeclareArguments::new(
&config.rabbit.default_exchange,
&ExchangeType::Topic.to_string(),
)
.durable(true)
.finish(),
)
.await
.expect("Failed to declare exchange");
// Configure acks channel & routing
self.channel
.queue_declare(
QueueDeclareArguments::new(&config.rabbit.queues.acks)
.durable(true)
.no_wait(true)
.finish(),
)
.await
.expect("Failed to bind queue");
self.channel
.queue_bind(QueueBindArguments::new(
&config.rabbit.queues.acks,
&config.rabbit.default_exchange,
&config.rabbit.queues.acks,
))
.await
.expect("Failed to bind channel");
Ok(())
}
pub async fn friend_request_accepted(
&self,
accepted_request_user: &User,
@@ -272,9 +232,7 @@ impl AMQP {
.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,
@@ -358,41 +316,4 @@ impl AMQP {
)
.await
}
/// # 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.channel
.basic_publish(
BasicProperties::default()
.with_content_type("application/json")
.with_persistence(true)
//.with_headers(headers)
.finish(),
payload.into(),
BasicPublishArguments::new(
&config.rabbit.default_exchange,
&config.rabbit.queues.acks,
),
)
.await
}
}

View File

@@ -10,7 +10,6 @@ use authifier::config::SMTPSettings;
use authifier::config::Shield;
use authifier::config::Template;
use authifier::config::Templates;
use authifier::config::EmailExpiryConfig;
use authifier::Authifier;
use rand::Rng;
use revolt_config::config;
@@ -70,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
}
@@ -91,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"),
}
@@ -138,33 +137,29 @@ impl Database {
.api
.smtp
.reply_to
.unwrap_or("support@stoat.chat".into()),
.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: EmailExpiryConfig {
expire_verification: 3600 * 24 * 7,
expire_password_reset: 3600 * 24,
expire_account_deletion: 3600 * 24,
},
expiry: Default::default(),
templates: if config.production {
Templates {
verify: Template {
title: "Verify your Stoat account.".into(),
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 Stoat password.".into(),
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 Stoat account, reset your password."
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),
@@ -214,6 +209,8 @@ impl Database {
} else {
EmailVerificationConfig::Disabled
},
sso: config.api.sso.clone(),
server_url: Some(config.hosts.api.parse().expect("Failed to parse API host url.")),
..Default::default()
};
@@ -249,6 +246,7 @@ impl Database {
event_channel: Some(crate::tasks::authifier_relay::sender()),
#[cfg(not(feature = "tasks"))]
event_channel: None,
..Default::default()
}
}
}

View File

@@ -3,7 +3,7 @@ use revolt_result::Error;
use serde::{Deserialize, Serialize};
use revolt_models::v0::{
AppendMessage, Channel, 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, ChannelVoiceState, Emoji, FieldsChannel, FieldsMember, FieldsMessage, FieldsRole, FieldsServer, FieldsUser, FieldsWebhook, Member, MemberCompositeKey, Message, PartialChannel, PartialMember, PartialMessage, PartialRole, PartialServer, PartialUser, PartialUserVoiceState, PartialWebhook, PolicyChange, RemovalIntention, Report, Server, User, UserSettings, UserVoiceState, Webhook
};
use crate::Database;
@@ -219,12 +219,6 @@ pub enum EventV1 {
/// New emoji
EmojiCreate(Emoji),
/// Update existing emoji
EmojiUpdate {
id: String,
data: PartialEmoji,
},
/// Delete emoji
EmojiDelete { id: String },

View File

@@ -78,11 +78,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

@@ -95,7 +95,7 @@ macro_rules! database_test {
db.drop_database().await;
#[allow(clippy::redundant_closure_call)]
std::boxed::Box::pin((|$db: $crate::Database| $test)(db.clone())).await;
(|$db: $crate::Database| $test)(db.clone()).await;
db.drop_database().await
};

View File

@@ -25,7 +25,7 @@ struct MigrationInfo {
revision: i32,
}
pub const LATEST_REVISION: i32 = 50; // MUST BE +1 to last migration
pub const LATEST_REVISION: i32 = 49; // MUST BE +1 to last migration
pub async fn migrate_database(db: &MongoDb) {
let migrations = db.col::<Document>("migrations");
@@ -1246,7 +1246,7 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.expect("Failed to update voice channels");
};
if revision <= 48 {
if revision <= 48 {
info!("Running migration [revision 48 / 22-10-2025]: Add Video + Listen to default permissions");
db.col::<Document>("servers")
@@ -1264,48 +1264,6 @@ pub async fn run_migrations(db: &MongoDb, revision: i32) -> i32 {
.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();
}
};
// Reminder to update LATEST_REVISION when adding new migrations.
LATEST_REVISION.max(revision)
}

View File

@@ -1,7 +1,6 @@
#![allow(deprecated)]
use std::{borrow::Cow, collections::HashMap};
use redis_kiss::get_connection;
use revolt_config::config;
use revolt_models::v0::{self, MessageAuthor};
use revolt_permissions::OverrideField;
@@ -111,10 +110,6 @@ auto_derived!(
/// 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>,
},
}
@@ -151,8 +146,6 @@ auto_derived!(
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
@@ -213,7 +206,6 @@ impl Channel {
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: data.voice.map(|voice| voice.into()),
slowmode: None,
},
v0::LegacyServerChannelType::Voice => Channel::TextChannel {
id: id.clone(),
@@ -226,7 +218,6 @@ impl Channel {
role_permissions: HashMap::new(),
nsfw: data.nsfw.unwrap_or(false),
voice: Some(data.voice.unwrap_or_default().into()),
slowmode: None,
},
};
@@ -644,7 +635,7 @@ impl Channel {
}
/// 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(),
@@ -653,7 +644,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

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;
@@ -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,26 +75,6 @@ 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() {

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 },

View File

@@ -17,12 +17,6 @@ pub trait AbstractAttachmentHashes: Sync + Send {
/// Update an attachment hash nonce value.
async fn set_attachment_hash_nonce(&self, hash: &str, nonce: &str) -> Result<()>;
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()>;
/// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()>;
}

View File

@@ -48,29 +48,6 @@ impl AbstractAttachmentHashes for MongoDb {
.map_err(|_| create_database_error!("update_one", COL))
}
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()> {
self.col::<FileHash>(COL)
.update_one(
doc! {
"_id": hash,
"metadata.type": "Image",
"metadata.animated": { "$exists": false },
},
doc! {
"$set": {
"metadata.animated": animated
}
},
)
.await
.map(|_| ())
.map_err(|_| create_database_error!("update_one", COL))
}
/// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()> {
query!(self, delete_one_by_id, COL, id).map(|_| ())

View File

@@ -1,6 +1,7 @@
use revolt_result::Result;
use crate::{FileHash, Metadata, ReferenceDb};
use crate::FileHash;
use crate::ReferenceDb;
use super::AbstractAttachmentHashes;
@@ -38,29 +39,6 @@ impl AbstractAttachmentHashes for ReferenceDb {
}
}
/// Updates the attachments animated metadata value.
///
/// The primary use for this is to update the metadata for existing uploaded files, this
/// can only be used for images.
async fn set_attachment_hash_animated(&self, hash: &str, animated: bool) -> Result<()> {
let mut hashes = self.file_hashes.lock().await;
if let Some(FileHash {
metadata:
Metadata::Image {
animated: Some(animated_metadata),
..
},
..
}) = hashes.get_mut(hash)
{
*animated_metadata = animated;
Ok(())
} else {
Err(create_error!(NotFound))
}
}
/// Delete attachment hash by id.
async fn delete_attachment_hash(&self, id: &str) -> Result<()> {
let mut file_hashes = self.file_hashes.lock().await;

View File

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

View File

@@ -1,3 +1,5 @@
use std::{collections::HashSet, hash::RandomState};
use indexmap::{IndexMap, IndexSet};
use iso8601_timestamp::Timestamp;
use revolt_config::{config, FeaturesLimits};
@@ -7,8 +9,6 @@ use revolt_models::v0::{
};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
use revolt_result::{ErrorType, Result};
use std::time::SystemTime;
use std::{collections::HashSet, hash::RandomState};
use ulid::Ulid;
use validator::Validate;
@@ -388,7 +388,6 @@ impl Message {
mut role_mentions,
mut mentions_everyone,
mut mentions_online,
..
} = message_mentions;
if allow_mass_mentions && server_id.is_some() && !role_mentions.is_empty() {
@@ -498,7 +497,7 @@ impl Message {
user_mentions.retain(|m| recipients_hash.contains(m));
role_mentions.clear();
}
Channel::TextChannel { ref server, .. } => {
Channel::TextChannel { ref server, .. }=> {
let mentions_vec = Vec::from_iter(user_mentions.iter().cloned());
let valid_members = db.fetch_members(server.as_str(), &mentions_vec[..]).await;
@@ -688,13 +687,8 @@ impl Message {
)
.await?;
let is_dm_or_group = matches!(
channel,
Channel::DirectMessage { .. } | Channel::Group { .. }
);
if !self.has_suppressed_notifications()
&& (is_dm_or_group || self.mentions.is_some() || self.contains_mass_push_mention())
&& (self.mentions.is_some() || self.contains_mass_push_mention())
{
// send Push notifications
#[cfg(feature = "tasks")]
@@ -705,7 +699,7 @@ impl Message {
Some(
PushNotification::from(
self.clone().into_model(user, member),
Some(author.clone()),
Some(author),
channel.to_owned().into(),
)
.await,
@@ -713,11 +707,7 @@ impl Message {
self.clone(),
match channel {
Channel::DirectMessage { recipients, .. }
| Channel::Group { recipients, .. } => recipients
.iter()
.filter(|uid| *uid != author.id())
.cloned()
.collect(),
| Channel::Group { recipients, .. } => recipients.clone(),
Channel::TextChannel { .. } => {
self.mentions.clone().unwrap_or_default()
}
@@ -1039,31 +1029,6 @@ impl Message {
Ok(())
}
/// Bulk delete messages by an author since a given time
pub async fn bulk_delete_by_author_since(
db: &Database,
channels: &[String],
author: &str,
since: SystemTime,
) -> Result<()> {
let deleted_groups = db
.delete_messages_by_author_since(channels, author, since)
.await?;
for (channel_id, message_ids) in deleted_groups {
if !message_ids.is_empty() {
EventV1::BulkMessageDelete {
channel: channel_id.clone(),
ids: message_ids,
}
.p(channel_id)
.await;
}
}
Ok(())
}
/// Remove a reaction from a message
pub async fn remove_reaction(&self, db: &Database, user: &str, emoji: &str) -> Result<()> {
// Check if it actually exists

View File

@@ -1,5 +1,3 @@
use std::collections::HashMap;
use std::time::SystemTime;
use revolt_result::Result;
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage};
@@ -42,12 +40,4 @@ pub trait AbstractMessages: Sync + Send {
/// Delete messages from a channel by their ids and corresponding channel id
async fn delete_messages(&self, channel: &str, ids: &[String]) -> Result<()>;
/// Delete all messages from a specific author in a server from a certain ULID onwards
async fn delete_messages_by_author_since(
&self,
channels: &[String],
author: &str,
since: SystemTime
) -> Result<HashMap<String, Vec<String>>>;
}

View File

@@ -1,12 +1,8 @@
use bson::{to_bson, Document};
use futures::try_join;
use futures::StreamExt;
use mongodb::options::FindOptions;
use revolt_models::v0::MessageSort;
use revolt_result::Result;
use std::collections::{HashMap, HashSet};
use std::time::SystemTime;
use ulid::Ulid;
use crate::{
AppendMessage, DocumentId, FieldsMessage, IntoDocumentPath, Message, MessageQuery,
@@ -310,112 +306,6 @@ impl AbstractMessages for MongoDb {
.map(|_| ())
.map_err(|_| create_database_error!("delete_many", COL))
}
/// Delete all messages from a specific author in a server from a certain ULID onwards
async fn delete_messages_by_author_since(
&self,
channels: &[String],
author: &str,
since: SystemTime,
) -> Result<HashMap<String, Vec<String>>> {
let threshold_ulid = Ulid::from_datetime(since).to_string();
let filter = doc! {
"author": author,
"channel": { "$in": channels },
"_id": { "$gte": &threshold_ulid }
};
let pipeline = vec![
doc! { "$match": filter.clone() },
doc! {
"$project": {
"channel": 1_i32,
"message_id": "$_id",
"attachment_ids": {
"$map": {
"input": { "$ifNull": ["$attachments", Vec::<bson::Bson>::new()] },
"as": "a",
"in": "$$a._id"
}
}
}
},
doc! {
"$group": {
"_id": "$channel",
"message_ids": { "$push": "$message_id" },
"attachment_ids_nested": { "$push": "$attachment_ids" }
}
},
doc! {
"$project": {
"message_ids": 1_i32,
"attachment_ids": {
"$reduce": {
"input": "$attachment_ids_nested",
"initialValue": Vec::<bson::Bson>::new(),
"in": { "$setUnion": ["$$value", "$$this"] }
}
}
}
},
];
#[derive(serde::Deserialize)]
struct AggregatedChannel {
#[serde(rename = "_id")]
channel: String,
message_ids: Vec<String>,
#[serde(default)]
attachment_ids: Vec<String>,
}
let mut cursor = self
.col::<Document>(COL)
.aggregate(pipeline)
.await
.map_err(|_| create_database_error!("aggregate", COL))?
.with_type::<AggregatedChannel>();
let mut deleted_messages: HashMap<String, Vec<String>> = HashMap::new();
let mut attachment_ids: HashSet<String> = HashSet::new();
while let Some(result) = cursor.next().await {
if let Ok(item) = result {
for id in item.attachment_ids {
attachment_ids.insert(id);
}
deleted_messages.insert(item.channel, item.message_ids);
}
}
// Mark attachments as deleted before deleting messages
if !attachment_ids.is_empty() {
self.col::<Document>("attachments")
.update_many(
doc! {
"_id": {
"$in": attachment_ids.into_iter().collect::<Vec<String>>()
}
},
doc! {
"$set": {
"deleted": true
}
},
)
.await
.map_err(|_| create_database_error!("update_many", "attachments"))?;
}
self.col::<Document>(COL)
.delete_many(filter)
.await
.map_err(|_| create_database_error!("delete_many", COL))?;
Ok(deleted_messages)
}
}
impl IntoDocumentPath for FieldsMessage {

View File

@@ -1,9 +1,7 @@
use std::collections::HashMap;
use futures::future::try_join_all;
use indexmap::IndexSet;
use revolt_result::Result;
use std::time::SystemTime;
use ulid::Ulid;
use crate::{AppendMessage, FieldsMessage, Message, MessageQuery, PartialMessage, ReferenceDb};
use super::AbstractMessages;
@@ -249,7 +247,7 @@ impl AbstractMessages for ReferenceDb {
let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) {
if let Some(users) = message.reactions.get_mut(emoji) {
users.swap_remove(&user.to_string());
users.remove(&user.to_string());
}
Ok(())
@@ -262,7 +260,7 @@ impl AbstractMessages for ReferenceDb {
async fn clear_reaction(&self, id: &str, emoji: &str) -> Result<()> {
let mut messages = self.messages.lock().await;
if let Some(message) = messages.get_mut(id) {
message.reactions.swap_remove(emoji);
message.reactions.remove(emoji);
Ok(())
} else {
Err(create_error!(NotFound))
@@ -288,63 +286,4 @@ impl AbstractMessages for ReferenceDb {
Ok(())
}
/// Delete all messages from a specific author in a list of channels from a certain ULID onwards
async fn delete_messages_by_author_since(
&self,
channels: &[String],
author: &str,
since: SystemTime
) -> Result<HashMap<String, Vec<String>>> {
let threshold_ulid = Ulid::from_datetime(since).to_string();
let mut deleted_messages: HashMap<String, Vec<String>> = HashMap::new();
let mut attachment_ids: Vec<String> = Vec::new();
let messages = self.messages.lock().await;
// First pass: collect attachment IDs and message IDs to delete
for (id, message) in messages.iter() {
let should_delete = message.author == author
&& channels.contains(&message.channel)
&& id.as_str() >= threshold_ulid.as_str();
if should_delete {
// Collect attachment IDs
if let Some(attachments) = &message.attachments {
for attachment in attachments {
attachment_ids.push(attachment.id.clone());
}
}
deleted_messages
.entry(message.channel.clone())
.or_default()
.push(id.clone());
}
}
drop(messages);
// Mark attachments as deleted
if !attachment_ids.is_empty() {
let mut files = self.files.lock().await;
for attachment_id in attachment_ids {
if let Some(file) = files.get_mut(&attachment_id) {
file.deleted = Some(true);
}
}
}
// Delete the messages
self.messages
.lock()
.await
.retain(|id, message| {
let should_keep = !(message.author == author
&& channels.contains(&message.channel)
&& id.as_str() >= threshold_ulid.as_str());
should_keep
});
Ok(deleted_messages)
}
}

View File

@@ -3,8 +3,8 @@ use revolt_permissions::{calculate_channel_permissions, ChannelPermission};
use revolt_result::{create_error, Result};
use crate::{
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel, Database, File,
Server, SystemMessage, User,
events::client::EventV1, util::permissions::DatabasePermissionQuery, Channel,
Database, File, Server, SystemMessage, User,
};
fn default_true() -> bool {
@@ -45,6 +45,7 @@ auto_derived_partial!(
/// Whether the member is server-wide voice deafened
#[serde(skip_serializing_if = "is_true", default = "default_true")]
pub can_receive: bool,
// This value only exists in the database, not the models.
// If it is not-None, the database layer should return None to member fetching queries.
// pub pending_deletion_at: Option<Timestamp>
@@ -71,7 +72,6 @@ auto_derived!(
CanReceive,
CanPublish,
JoinedAt,
VoiceChannel,
}
/// Member removal intention
@@ -152,11 +152,7 @@ impl Member {
#[cfg(feature = "voice")]
for channel in &channels {
if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state(
&crate::voice::UserVoiceChannel::from_channel(channel),
)
.await
{
if let Ok(Some(voice_state)) = crate::voice::get_channel_voice_state(channel).await {
voice_states.push(voice_state)
}
}
@@ -178,7 +174,7 @@ impl Member {
.map(|channel| channel.into())
.collect(),
emojis: emojis.into_iter().map(|emoji| emoji.into()).collect(),
voice_states,
voice_states
}
.private(user.id.clone())
.await;
@@ -228,14 +224,13 @@ impl Member {
pub fn remove_field(&mut self, field: &FieldsMember) {
match field {
FieldsMember::JoinedAt => {}
FieldsMember::JoinedAt => (),
FieldsMember::Avatar => self.avatar = None,
FieldsMember::Nickname => self.nickname = None,
FieldsMember::Roles => self.roles.clear(),
FieldsMember::Timeout => self.timeout = None,
FieldsMember::CanReceive => self.can_receive = true,
FieldsMember::CanPublish => self.can_publish = true,
FieldsMember::VoiceChannel => {}
}
}

View File

@@ -330,15 +330,14 @@ impl AbstractServerMembers for MongoDb {
impl IntoDocumentPath for FieldsMember {
fn as_path(&self) -> Option<&'static str> {
match self {
FieldsMember::JoinedAt => Some("joined_at"),
FieldsMember::Avatar => Some("avatar"),
FieldsMember::Nickname => Some("nickname"),
FieldsMember::Roles => Some("roles"),
FieldsMember::Timeout => Some("timeout"),
FieldsMember::CanPublish => Some("can_publish"),
FieldsMember::CanReceive => Some("can_receive"),
FieldsMember::VoiceChannel => None,
}
Some(match self {
FieldsMember::JoinedAt => "joined_at",
FieldsMember::Avatar => "avatar",
FieldsMember::Nickname => "nickname",
FieldsMember::Roles => "roles",
FieldsMember::Timeout => "timeout",
FieldsMember::CanPublish => "can_publish",
FieldsMember::CanReceive => "can_receive",
})
}
}

View File

@@ -68,9 +68,6 @@ auto_derived_partial!(
auto_derived_partial!(
/// Role
pub struct Role {
/// Unique Id
#[serde(rename = "_id")]
pub id: String,
/// Role name
pub name: String,
/// Permissions available to this role
@@ -86,9 +83,6 @@ auto_derived_partial!(
/// Ranking of this role
#[serde(default)]
pub rank: i64,
/// Custom icon attachment
#[serde(skip_serializing_if = "Option::is_none")]
pub icon: Option<File>,
},
"PartialRole"
);
@@ -132,7 +126,6 @@ auto_derived!(
/// Optional fields on server object
pub enum FieldsRole {
Colour,
Icon,
}
);
@@ -253,6 +246,7 @@ impl Server {
role.update(
db,
&self.id,
role_id,
PartialRole {
permissions: Some(permissions),
..Default::default()
@@ -303,41 +297,29 @@ impl Role {
/// Into optional struct
pub fn into_optional(self) -> PartialRole {
PartialRole {
id: Some(self.id),
name: Some(self.name),
permissions: Some(self.permissions),
colour: self.colour,
hoist: Some(self.hoist),
rank: Some(self.rank),
icon: self.icon,
}
}
/// Create a role
pub async fn create(db: &Database, server: &Server, name: String) -> Result<Self> {
let role = Role {
id: Ulid::new().to_string(),
name,
// Rank of the new role should be below the lowest role
rank: server.roles.len() as i64,
colour: None,
hoist: false,
permissions: Default::default(),
icon: None,
};
db.insert_role(&server.id, &role).await?;
pub async fn create(&self, db: &Database, server_id: &str) -> Result<String> {
let role_id = Ulid::new().to_string();
db.insert_role(server_id, &role_id, self).await?;
EventV1::ServerRoleUpdate {
id: server.id.clone(),
role_id: role.id.clone(),
data: role.clone().into_optional().into(),
id: server_id.to_string(),
role_id: role_id.to_string(),
data: self.clone().into_optional().into(),
clear: vec![],
}
.p(server.id.clone())
.p(server_id.to_string())
.await;
Ok(role)
Ok(role_id)
}
/// Update server data
@@ -345,6 +327,7 @@ impl Role {
&mut self,
db: &Database,
server_id: &str,
role_id: &str,
partial: PartialRole,
remove: Vec<FieldsRole>,
) -> Result<()> {
@@ -354,14 +337,14 @@ impl Role {
self.apply_options(partial.clone());
db.update_role(server_id, &self.id, &partial, remove.clone())
db.update_role(server_id, role_id, &partial, remove.clone())
.await?;
EventV1::ServerRoleUpdate {
id: server_id.to_string(),
role_id: self.id.clone(),
role_id: role_id.to_string(),
data: partial.into(),
clear: remove.into_iter().map(Into::into).collect(),
clear: vec![],
}
.p(server_id.to_string())
.await;
@@ -373,20 +356,19 @@ impl Role {
pub fn remove_field(&mut self, field: &FieldsRole) {
match field {
FieldsRole::Colour => self.colour = None,
FieldsRole::Icon => self.icon = None,
}
}
/// Delete a role
pub async fn delete(self, db: &Database, server_id: &str) -> Result<()> {
pub async fn delete(self, db: &Database, server_id: &str, role_id: &str) -> Result<()> {
EventV1::ServerRoleDelete {
id: server_id.to_string(),
role_id: self.id.clone(),
role_id: role_id.to_string(),
}
.p(server_id.to_string())
.await;
db.delete_role(server_id, &self.id).await
db.delete_role(server_id, role_id).await
}
}

View File

@@ -29,7 +29,7 @@ pub trait AbstractServers: Sync + Send {
async fn delete_server(&self, id: &str) -> Result<()>;
/// Insert a new role into server object
async fn insert_role(&self, server_id: &str, role: &Role) -> Result<()>;
async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()>;
/// Update an existing role on a server
async fn update_role(

View File

@@ -69,7 +69,7 @@ impl AbstractServers for MongoDb {
}
/// Insert a new role into server object
async fn insert_role(&self, server_id: &str, role: &Role) -> Result<()> {
async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()> {
self.col::<Document>(COL)
.update_one(
doc! {
@@ -77,7 +77,7 @@ impl AbstractServers for MongoDb {
},
doc! {
"$set": {
"roles.".to_owned() + role.id.as_str(): to_document(role)
"roles.".to_owned() + role_id: to_document(role)
.map_err(|_| create_database_error!("to_document", "role"))?
}
},
@@ -172,7 +172,6 @@ impl IntoDocumentPath for FieldsRole {
fn as_path(&self) -> Option<&'static str> {
Some(match self {
FieldsRole::Colour => "colour",
FieldsRole::Icon => "icon",
})
}
}

View File

@@ -72,10 +72,10 @@ impl AbstractServers for ReferenceDb {
}
/// Insert a new role into server object
async fn insert_role(&self, server_id: &str, role: &Role) -> Result<()> {
async fn insert_role(&self, server_id: &str, role_id: &str, role: &Role) -> Result<()> {
let mut servers = self.servers.lock().await;
if let Some(server) = servers.get_mut(server_id) {
server.roles.insert(role.id.clone(), role.clone());
server.roles.insert(role_id.to_string(), role.clone());
Ok(())
} else {
Err(create_error!(NotFound))

View File

@@ -7,7 +7,6 @@ use futures::future::join_all;
use iso8601_timestamp::Timestamp;
use once_cell::sync::Lazy;
use rand::seq::SliceRandom;
use regex::{Regex, RegexBuilder};
use revolt_config::{config, FeaturesLimits};
use revolt_models::v0::{self, UserBadges, UserFlags};
use revolt_presence::filter_online;
@@ -164,13 +163,6 @@ pub static DISCRIMINATOR_SEARCH_SPACE: Lazy<HashSet<String>> = Lazy::new(|| {
set.into_iter().collect()
});
static BLOCKED_USERNAME_PATTERNS: Lazy<Regex> = Lazy::new(|| {
RegexBuilder::new("`{3}|(discord|rvlt|guilded|stt)\\.gg|(revolt|stoat)\\.chat|https?:\\/\\/")
.case_insensitive(true)
.build()
.unwrap()
});
#[allow(clippy::derivable_impls)]
impl Default for User {
fn default() -> Self {
@@ -206,13 +198,11 @@ impl User {
I: Into<Option<String>>,
D: Into<Option<PartialUser>>,
{
let new_username = User::sanitise_username(&username).await?;
User::validate_username(&new_username)?;
let username = User::validate_username(username)?;
let mut user = User {
id: account_id.into().unwrap_or_else(|| Ulid::new().to_string()),
discriminator: User::find_discriminator(db, &new_username, None).await?,
username: new_username.clone(),
discriminator: User::find_discriminator(db, &username, None).await?,
username,
last_acknowledged_policy_change: Timestamp::now_utc(),
..Default::default()
};
@@ -288,40 +278,39 @@ impl User {
}
}
/// Validate a username
///
/// This will check if the username is a blocked name or contains a blocked pattern.
fn validate_username(username: &str) -> Result<()> {
/// Sanitise and validate a username can be used
pub fn validate_username(username: String) -> Result<String> {
// Copy the username for validation
let username_lowercase = username.to_lowercase();
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt", "stoat"];
if BLOCKED_USERNAMES.contains(&username_lowercase.as_str())
|| BLOCKED_USERNAME_PATTERNS.is_match(username)
{
// Block homoglyphs
if decancer::cure(&username_lowercase).into_str() != username_lowercase {
return Err(create_error!(InvalidUsername));
}
Ok(())
}
// Ensure the username itself isn't blocked
const BLOCKED_USERNAMES: &[&str] = &["admin", "revolt"];
/// Sanitise a username
///
/// This will clean up Unicode homoglyphs and pad to the min username length with underscores.
async fn sanitise_username(username: &str) -> Result<String> {
let options = decancer::Options::default().retain_capitalization();
let mut username = decancer::cure(username, options)
.map_err(|_| create_error!(InvalidUsername))?
.to_string();
for username in BLOCKED_USERNAMES {
if username_lowercase == *username {
return Err(create_error!(InvalidUsername));
}
}
let config = revolt_config::config().await;
let username_length_diff = config
.api
.users
.min_username_length
.saturating_sub(username.len());
if username_length_diff > 0 {
username.push_str(&"_".repeat(username_length_diff))
// Ensure none of the following substrings show up in the username
const BLOCKED_SUBSTRINGS: &[&str] = &[
"```",
"discord.gg",
"rvlt.gg",
"guilded.gg",
"https://",
"http://",
];
for substr in BLOCKED_SUBSTRINGS {
if username_lowercase.contains(substr) {
return Err(create_error!(InvalidUsername));
}
}
Ok(username)
@@ -427,14 +416,12 @@ impl User {
/// Update a user's username
pub async fn update_username(&mut self, db: &Database, username: String) -> Result<()> {
let new_username = User::sanitise_username(&username).await?;
User::validate_username(&new_username)?;
if self.username.to_lowercase() == new_username.to_lowercase() {
let username = User::validate_username(username)?;
if self.username.to_lowercase() == username.to_lowercase() {
self.update(
db,
PartialUser {
username: Some(new_username),
username: Some(username),
..Default::default()
},
vec![],
@@ -447,12 +434,12 @@ impl User {
discriminator: Some(
User::find_discriminator(
db,
&new_username,
&username,
Some((self.discriminator.to_string(), self.id.clone())),
)
.await?,
),
username: Some(new_username),
username: Some(username),
..Default::default()
},
vec![],
@@ -838,109 +825,3 @@ impl User {
badges
}
}
#[cfg(test)]
mod tests {
use crate::User;
#[test]
fn username_validation_blocked_names() {
let username_admin = "Admin";
let username_revolt = "Revolt";
let username_stoat = "Stoat";
let username_allowed = "Allowed";
assert!(User::validate_username(username_admin).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_allowed).is_ok());
}
#[test]
fn username_validation_blocked_patterns() {
let username_grave = "```_test";
let username_discord = "discord.gg_test";
let username_rvlt = "rvlt.gg_test";
let username_guilded = "guilded.gg_test";
let username_stt = "stt.gg_test";
let username_revolt = "revolt.chat_test";
let username_stoat = "stoat.chat_test";
let username_http = "http://_test";
let username_https = "https://_test";
assert!(User::validate_username(username_grave).is_err());
assert!(User::validate_username(username_discord).is_err());
assert!(User::validate_username(username_rvlt).is_err());
assert!(User::validate_username(username_guilded).is_err());
assert!(User::validate_username(username_stt).is_err());
assert!(User::validate_username(username_revolt).is_err());
assert!(User::validate_username(username_stoat).is_err());
assert!(User::validate_username(username_http).is_err());
assert!(User::validate_username(username_https).is_err());
}
#[async_std::test]
async fn username_sanitisation_clean() {
let username_clean = "Test";
let username_clean_sanitised = User::sanitise_username(username_clean).await;
assert!(username_clean_sanitised.is_ok());
assert_eq!(username_clean, username_clean_sanitised.unwrap());
}
#[async_std::test]
async fn username_sanitisation_homoglyphs() {
let username_homoglyphs = "𝔽𝕌Ňℕy";
let username_homoglyphs_sanitised =
User::sanitise_username(username_homoglyphs).await.unwrap();
assert_ne!(username_homoglyphs, username_homoglyphs_sanitised);
assert_eq!("funny", username_homoglyphs_sanitised);
}
#[async_std::test]
async fn username_sanitisation_padding() {
let username_padding = "a";
let username = User::sanitise_username(username_padding).await.unwrap();
assert_eq!("a_", username);
}
#[async_std::test]
async fn create_user() {
use revolt_result::Result;
database_test!(|db| async move {
let mut created_clean = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
assert_eq!("Test", created_clean.username);
created_clean
.update_username(&db, "Test2".to_string())
.await
.unwrap();
assert_eq!("Test2", created_clean.username);
let created_invalid_result: Result<_> =
User::create(&db, "stoat.chat".to_string(), None, None).await;
assert!(created_invalid_result.is_err());
let mut updated_invalid = User::create(&db, "Test".to_string(), None, None)
.await
.unwrap();
let updated_invalid_update_result = updated_invalid
.update_username(&db, "http://test".to_string())
.await;
assert!(updated_invalid_update_result.is_err());
});
}
}

View File

@@ -105,11 +105,7 @@ pub async fn handle_ack_event(
if mentions_acked > 0 {
if let Err(err) = amqp
.ack_notification_message(
user.to_string(),
channel.to_string(),
id.to_owned(),
)
.ack_message(user.to_string(), channel.to_string(), id.to_owned())
.await
{
revolt_config::capture_error(&err);
@@ -196,7 +192,9 @@ pub async fn handle_ack_event(
.expect("Failed to fetch channel from db");
if let TextChannel { server, .. } = channel {
if let Err(err) = amqp.mass_mention_message_sent(server, mass_mentions).await {
if let Err(err) =
amqp.mass_mention_message_sent(server, mass_mentions).await
{
revolt_config::capture_error(&err);
}
} else {

View File

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

View File

@@ -190,7 +190,6 @@ impl From<crate::Channel> for Channel {
role_permissions,
nsfw,
voice,
slowmode,
} => Channel::TextChannel {
id,
server,
@@ -202,7 +201,6 @@ impl From<crate::Channel> for Channel {
role_permissions,
nsfw,
voice: voice.map(|voice| voice.into()),
slowmode,
},
}
}
@@ -256,7 +254,6 @@ impl From<Channel> for crate::Channel {
role_permissions,
nsfw,
voice,
slowmode,
} => crate::Channel::TextChannel {
id,
server,
@@ -268,7 +265,6 @@ impl From<Channel> for crate::Channel {
role_permissions,
nsfw,
voice: voice.map(|voice| voice.into()),
slowmode,
},
}
}
@@ -287,8 +283,7 @@ impl From<crate::PartialChannel> for PartialChannel {
role_permissions: value.role_permissions,
default_permissions: value.default_permissions,
last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into()),
slowmode: value.slowmode,
voice: value.voice.map(|voice| voice.into())
}
}
}
@@ -306,8 +301,7 @@ impl From<PartialChannel> for crate::PartialChannel {
role_permissions: value.role_permissions,
default_permissions: value.default_permissions,
last_message_id: value.last_message_id,
voice: value.voice.map(|voice| voice.into()),
slowmode: value.slowmode,
voice: value.voice.map(|voice| voice.into())
}
}
}
@@ -412,16 +406,9 @@ impl From<crate::Metadata> for Metadata {
match value {
crate::Metadata::File => Metadata::File,
crate::Metadata::Text => Metadata::Text,
crate::Metadata::Image {
width,
height,
thumbhash,
animated,
} => Metadata::Image {
crate::Metadata::Image { width, height } => Metadata::Image {
width: width as usize,
height: height as usize,
thumbhash,
animated,
},
crate::Metadata::Video { width, height } => Metadata::Video {
width: width as usize,
@@ -437,16 +424,9 @@ impl From<Metadata> for crate::Metadata {
match value {
Metadata::File => crate::Metadata::File,
Metadata::Text => crate::Metadata::Text,
Metadata::Image {
width,
height,
thumbhash,
animated,
} => crate::Metadata::Image {
Metadata::Image { width, height } => crate::Metadata::Image {
width: width as isize,
height: height as isize,
thumbhash,
animated,
},
Metadata::Video { width, height } => crate::Metadata::Video {
width: width as isize,
@@ -535,9 +515,7 @@ impl From<crate::SystemMessage> for SystemMessage {
crate::SystemMessage::UserRemove { id, by } => Self::UserRemove { id, by },
crate::SystemMessage::MessagePinned { id, by } => Self::MessagePinned { id, by },
crate::SystemMessage::MessageUnpinned { id, by } => Self::MessageUnpinned { id, by },
crate::SystemMessage::CallStarted { by, finished_at } => {
Self::CallStarted { by, finished_at }
}
crate::SystemMessage::CallStarted { by, finished_at } => Self::CallStarted { by, finished_at }
}
}
}
@@ -713,7 +691,6 @@ impl From<crate::FieldsMember> for FieldsMember {
crate::FieldsMember::CanReceive => FieldsMember::CanReceive,
crate::FieldsMember::CanPublish => FieldsMember::CanPublish,
crate::FieldsMember::JoinedAt => FieldsMember::JoinedAt,
crate::FieldsMember::VoiceChannel => FieldsMember::VoiceChannel,
}
}
}
@@ -728,7 +705,6 @@ impl From<FieldsMember> for crate::FieldsMember {
FieldsMember::CanReceive => crate::FieldsMember::CanReceive,
FieldsMember::CanPublish => crate::FieldsMember::CanPublish,
FieldsMember::JoinedAt => crate::FieldsMember::JoinedAt,
FieldsMember::VoiceChannel => crate::FieldsMember::VoiceChannel,
}
}
}
@@ -920,13 +896,11 @@ impl From<SystemMessageChannels> for crate::SystemMessageChannels {
impl From<crate::Role> for Role {
fn from(value: crate::Role) -> Self {
Role {
id: value.id,
name: value.name,
permissions: value.permissions,
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -934,13 +908,11 @@ impl From<crate::Role> for Role {
impl From<Role> for crate::Role {
fn from(value: Role) -> crate::Role {
crate::Role {
id: value.id,
name: value.name,
permissions: value.permissions,
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -948,13 +920,11 @@ impl From<Role> for crate::Role {
impl From<crate::PartialRole> for PartialRole {
fn from(value: crate::PartialRole) -> Self {
PartialRole {
id: value.id,
name: value.name,
permissions: value.permissions,
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -962,13 +932,11 @@ impl From<crate::PartialRole> for PartialRole {
impl From<PartialRole> for crate::PartialRole {
fn from(value: PartialRole) -> crate::PartialRole {
crate::PartialRole {
id: value.id,
name: value.name,
permissions: value.permissions,
colour: value.colour,
hoist: value.hoist,
rank: value.rank,
icon: value.icon.map(|f| f.into()),
}
}
}
@@ -977,7 +945,6 @@ impl From<crate::FieldsRole> for FieldsRole {
fn from(value: crate::FieldsRole) -> Self {
match value {
crate::FieldsRole::Colour => FieldsRole::Colour,
crate::FieldsRole::Icon => FieldsRole::Icon,
}
}
}
@@ -986,7 +953,6 @@ impl From<FieldsRole> for crate::FieldsRole {
fn from(value: FieldsRole) -> Self {
match value {
FieldsRole::Colour => crate::FieldsRole::Colour,
FieldsRole::Icon => crate::FieldsRole::Icon,
}
}
}
@@ -1410,7 +1376,7 @@ impl From<FieldsMessage> for crate::FieldsMessage {
impl From<VoiceInformation> for crate::VoiceInformation {
fn from(value: VoiceInformation) -> Self {
crate::VoiceInformation {
max_users: value.max_users,
max_users: value.max_users
}
}
}
@@ -1418,7 +1384,7 @@ impl From<VoiceInformation> for crate::VoiceInformation {
impl From<crate::VoiceInformation> for VoiceInformation {
fn from(value: crate::VoiceInformation) -> Self {
VoiceInformation {
max_users: value.max_users,
max_users: value.max_users
}
}
}
}

View File

@@ -1,4 +1,3 @@
pub mod acker;
pub mod bridge;
pub mod bulk_permissions;
mod funcs;

View File

@@ -1,5 +1,3 @@
use std::fmt::{Display, Write};
use crate::{
events::client::EventV1,
models::{Channel, User},
@@ -8,11 +6,7 @@ use crate::{
};
use iso8601_timestamp::{Duration, Timestamp};
use livekit_protocol::ParticipantPermission;
use redis_kiss::{
get_connection as _get_connection,
redis::{FromRedisValue, Pipeline, RedisError, RedisWrite, ToRedisArgs, Value},
AsyncCommands, Conn,
};
use redis_kiss::{get_connection as _get_connection, redis::Pipeline, AsyncCommands, Conn};
use revolt_config::FeaturesLimits;
use revolt_models::v0::{self, PartialUserVoiceState, UserVoiceState};
use revolt_permissions::{calculate_channel_permissions, ChannelPermission, PermissionValue};
@@ -22,60 +16,48 @@ mod voice_client;
pub use voice_client::VoiceClient;
async fn get_connection() -> Result<Conn> {
_get_connection()
.await
.map_err(|_| create_error!(InternalError))
_get_connection().await.map_err(|_| create_error!(InternalError))
}
pub async fn raise_if_in_voice(user: &User, channel: &UserVoiceChannel) -> Result<()> {
pub async fn raise_if_in_voice(user: &User, channel_id: &str) -> Result<()> {
let mut conn = get_connection().await?;
if user.bot.is_some() {
// bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to
if conn
.sismember(format!("vc:{}", &user.id), channel)
if user.bot.is_some()
// bots can be in as many voice channels as it wants so we just check if its already connected to the one its trying to connect to
&& conn.sismember(format!("vc:{}", &user.id), channel_id)
.await
.to_internal_error()?
{
return Err(create_error!(AlreadyConnected));
};
{
Err(create_error!(AlreadyConnected))
} else if conn
.scard::<_, u32>(format!("vc:{}", &user.id)) // check if the current vc set is empty
.await
.to_internal_error()?
> 0
{
return Err(create_error!(AlreadyConnected));
};
Ok(())
Err(create_error!(AlreadyConnected))
} else {
Ok(())
}
}
pub async fn set_channel_node(channel_id: &str, node: &str) -> Result<()> {
pub async fn set_channel_node(channel: &str, node: &str) -> Result<()> {
get_connection()
.await?
.set(format!("node:{channel_id}"), node)
.set(format!("node:{channel}"), node)
.await
.to_internal_error()
}
pub async fn get_channel_node(channel_id: &str) -> Result<Option<String>> {
pub async fn get_channel_node(channel: &str) -> Result<Option<String>> {
get_connection()
.await?
.get(format!("node:{channel_id}"))
.get(format!("node:{channel}"))
.await
.to_internal_error()
}
pub async fn delete_channel_node(channel_id: &str) -> Result<()> {
get_connection()
.await?
.del(format!("node:{channel_id}"))
.await
.to_internal_error()
}
pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<UserVoiceChannel>> {
pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<String>> {
get_connection()
.await?
.smembers(format!("vc:{user_id}"))
@@ -84,14 +66,14 @@ pub async fn get_user_voice_channels(user_id: &str) -> Result<Vec<UserVoiceChann
}
pub async fn set_user_moved_from_voice(
old_channel_id: &str,
new_channel: &UserVoiceChannel,
old_channel: &str,
new_channel: &str,
user_id: &str,
) -> Result<()> {
get_connection()
.await?
.set_ex(
format!("moved_from:{user_id}:{old_channel_id}"),
format!("moved_from:{user_id}:{old_channel}"),
new_channel,
10,
)
@@ -108,25 +90,18 @@ pub async fn get_user_moved_from_voice(channel_id: &str, user_id: &str) -> Resul
}
pub async fn set_user_moved_to_voice(
new_channel_id: &str,
old_channel: &UserVoiceChannel,
new_channel: &str,
old_channel: &str,
user_id: &str,
) -> Result<()> {
get_connection()
.await?
.set_ex(
format!("moved_to:{user_id}:{new_channel_id}"),
old_channel,
10,
)
.set_ex(format!("moved_to:{user_id}:{new_channel}"), old_channel, 10)
.await
.to_internal_error()
}
pub async fn get_user_moved_to_voice(
channel_id: &str,
user_id: &str,
) -> Result<Option<UserVoiceChannel>> {
pub async fn get_user_moved_to_voice(channel_id: &str, user_id: &str) -> Result<Option<String>> {
get_connection()
.await?
.get_del(format!("moved_to:{user_id}:{channel_id}"))
@@ -134,10 +109,10 @@ pub async fn get_user_moved_to_voice(
.to_internal_error()
}
pub async fn is_in_voice_channel(user_id: &str, channel: &UserVoiceChannel) -> Result<bool> {
pub async fn is_in_voice_channel(user_id: &str, channel_id: &str) -> Result<bool> {
get_connection()
.await?
.sismember(format!("vc:{user_id}"), channel)
.sismember(format!("vc:{user_id}"), channel_id)
.await
.to_internal_error()
}
@@ -171,15 +146,12 @@ pub fn get_allowed_sources(
}
pub async fn create_voice_state(
channel: &UserVoiceChannel,
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
joined_at: Timestamp,
) -> Result<UserVoiceState> {
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
let voice_state = UserVoiceState {
joined_at,
@@ -191,9 +163,9 @@ pub async fn create_voice_state(
};
Pipeline::new()
.sadd(format!("vc_members:{}", &channel.id), user_id)
.sadd(format!("vc:{user_id}"), channel)
.set(&unique_key, &channel.id)
.sadd(format!("vc_members:{channel_id}"), user_id)
.sadd(format!("vc:{user_id}"), channel_id)
.set(&unique_key, channel_id)
.set(
format!("joined_at:{unique_key}"),
joined_at
@@ -220,16 +192,16 @@ pub async fn create_voice_state(
Ok(voice_state)
}
pub async fn delete_voice_state(channel: &UserVoiceChannel, user_id: &str) -> Result<()> {
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
pub async fn delete_voice_state(
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
) -> Result<()> {
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
Pipeline::new()
.srem(format!("vc_members:{}", &channel.id), user_id)
.srem(format!("vc:{user_id}"), channel)
.srem(format!("vc_members:{channel_id}"), user_id)
.srem(format!("vc:{user_id}"), channel_id)
.del(&[
format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"),
@@ -244,19 +216,19 @@ pub async fn delete_voice_state(channel: &UserVoiceChannel, user_id: &str) -> Re
}
pub async fn delete_channel_voice_state(
channel: &UserVoiceChannel,
channel_id: &str,
server_id: Option<&str>,
user_ids: &[String],
) -> Result<()> {
let parent_id = channel.server_id.as_ref().unwrap_or(&channel.id);
let parent_id = server_id.unwrap_or(channel_id);
let mut pipeline = Pipeline::new();
pipeline.del(format!("vc_members:{}", &channel.id));
pipeline.del(format!("node:{}", &channel.id));
pipeline.del(format!("vc_members:{channel_id}"));
for user_id in user_ids {
let unique_key = format!("{user_id}:{parent_id}");
pipeline.srem(format!("vc:{user_id}"), channel).del(&[
pipeline.srem(format!("vc:{user_id}"), channel_id).del(&[
format!("joined_at:{unique_key}"),
format!("is_publishing:{unique_key}"),
format!("is_receiving:{unique_key}"),
@@ -273,7 +245,8 @@ pub async fn delete_channel_voice_state(
}
pub async fn update_voice_state_tracks(
channel: &UserVoiceChannel,
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
added: bool,
track: i32,
@@ -298,21 +271,18 @@ pub async fn update_voice_state_tracks(
_ => unreachable!(),
};
update_voice_state(channel, user_id, &partial).await?;
update_voice_state(channel_id, server_id, user_id, &partial).await?;
Ok(partial)
}
pub async fn update_voice_state(
channel: &UserVoiceChannel,
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
partial: &PartialUserVoiceState,
) -> Result<()> {
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
let unique_key = format!("{}:{}", &user_id, server_id.unwrap_or(channel_id));
let mut pipeline = Pipeline::new();
@@ -338,24 +308,21 @@ pub async fn update_voice_state(
.to_internal_error()
}
pub async fn get_voice_channel_members(channel: &UserVoiceChannel) -> Result<Option<Vec<String>>> {
pub async fn get_voice_channel_members(channel_id: &str) -> Result<Option<Vec<String>>> {
get_connection()
.await?
.smembers::<_, Option<Vec<String>>>(format!("vc_members:{}", &channel.id))
.smembers::<_, Option<Vec<String>>>(format!("vc_members:{channel_id}"))
.await
.to_internal_error()
.map(|opt| opt.and_then(|v| if v.is_empty() { None } else { Some(v) }))
}
pub async fn get_voice_state(
channel: &UserVoiceChannel,
channel_id: &str,
server_id: Option<&str>,
user_id: &str,
) -> Result<Option<UserVoiceState>> {
let unique_key = format!(
"{}:{}",
&user_id,
channel.server_id.as_ref().unwrap_or(&channel.id)
);
let unique_key = format!("{}:{}", user_id, server_id.unwrap_or(channel_id));
let (joined_at, is_publishing, is_receiving, screensharing, camera) = get_connection()
.await?
@@ -396,21 +363,21 @@ pub async fn get_voice_state(
}
}
pub async fn get_channel_voice_state(
channel: &UserVoiceChannel,
) -> Result<Option<v0::ChannelVoiceState>> {
let members = get_voice_channel_members(channel).await?;
pub async fn get_channel_voice_state(channel: &Channel) -> Result<Option<v0::ChannelVoiceState>> {
let members = get_voice_channel_members(channel.id()).await?;
let server = channel.server();
if let Some(members) = members {
let mut participants = Vec::with_capacity(members.len());
for user_id in members {
if let Some(voice_state) = get_voice_state(channel, &user_id).await? {
if let Some(voice_state) = get_voice_state(channel.id(), server, &user_id).await? {
participants.push(voice_state);
} else {
log::info!("Voice state not found but member in voice channel members, removing.");
delete_voice_state(channel, &user_id).await?;
delete_voice_state(channel.id(), server, &user_id).await?;
}
}
@@ -418,7 +385,7 @@ pub async fn get_channel_voice_state(
participants.shrink_to_fit();
Ok(Some(v0::ChannelVoiceState {
id: channel.id.clone(),
id: channel.id().to_string(),
participants,
}))
} else {
@@ -426,12 +393,12 @@ pub async fn get_channel_voice_state(
}
}
pub async fn move_user(user: &str, from_channel_id: &str, to_channel_id: &str) -> Result<()> {
pub async fn move_user(user: &str, from: &str, to: &str) -> Result<()> {
get_connection()
.await?
.smove(
format!("vc_members:{from_channel_id}"),
format!("vc_members:{to_channel_id}"),
format!("vc-members-{from}"),
format!("vc-members-{to}"),
user,
)
.await
@@ -445,13 +412,11 @@ pub async fn sync_voice_permissions(
server: Option<&Server>,
role_id: Option<&str>,
) -> Result<()> {
let user_voice_channel = UserVoiceChannel::from_channel(channel);
let Some(node) = get_channel_node(channel.id()).await? else {
return Ok(());
};
for user_id in get_voice_channel_members(&user_voice_channel)
for user_id in get_voice_channel_members(channel.id())
.await?
.iter()
.flatten()
@@ -491,9 +456,7 @@ pub async fn sync_user_voice_permissions(
.as_ref()
.is_none_or(|member| member.roles.iter().any(|r| r == role_id))
}) {
let user_voice_channel = UserVoiceChannel::from_channel(channel);
let Some(voice_state) = get_voice_state(&user_voice_channel, &user.id).await? else {
let Some(voice_state) = get_voice_state(channel_id, server_id, &user.id).await? else {
return Ok(());
};
@@ -524,7 +487,7 @@ pub async fn sync_user_voice_permissions(
update_event.screensharing = voice_state.screensharing.then_some(can_video);
update_event.is_publishing = voice_state.is_publishing.then_some(can_speak);
update_voice_state(&user_voice_channel, &user.id, &update_event).await?;
update_voice_state(channel_id, server_id, &user.id, &update_event).await?;
voice_client
.update_permissions(
@@ -602,95 +565,34 @@ pub async fn get_call_notification_recipients(
.to_internal_error()
}
pub async fn remove_user_from_voice_channels(
voice_client: &VoiceClient,
user_id: &str,
) -> Result<()> {
for channel in get_user_voice_channels(user_id).await? {
remove_user_from_voice_channel(voice_client, &channel, user_id).await?;
}
Ok(())
}
pub async fn remove_user_from_voice_channel(
voice_client: &VoiceClient,
channel: &UserVoiceChannel,
user_id: &str,
) -> Result<()> {
if let Some(node) = get_channel_node(&channel.id).await? {
let _ = voice_client.remove_user(&node, user_id, &channel.id).await;
}
delete_voice_state(channel, user_id).await?;
Ok(())
}
pub async fn delete_voice_channel(
voice_client: &VoiceClient,
channel: &UserVoiceChannel,
) -> Result<()> {
if let Some(users) = get_voice_channel_members(channel).await? {
let node = get_channel_node(&channel.id).await?.unwrap();
voice_client.delete_room(&node, &channel.id).await?;
delete_channel_voice_state(channel, &users).await?;
pub async fn remove_user_from_voice_channels(db: &Database, voice_client: &VoiceClient, user_id: &str) -> Result<()> {
for channel_id in get_user_voice_channels(user_id).await? {
remove_user_from_voice_channel(db, voice_client, &channel_id, user_id).await?;
};
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RoomMetadata {
pub server: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UserVoiceChannel {
pub id: String,
pub server_id: Option<String>,
}
impl UserVoiceChannel {
pub fn from_string(input: String) -> Self {
let mut parts = input.splitn(2, '-');
Self {
id: parts.next().unwrap().to_string(),
server_id: parts.next().map(ToString::to_string),
}
pub async fn remove_user_from_voice_channel(db: &Database, voice_client: &VoiceClient, channel_id: &str, user_id: &str) -> Result<()> {
if let Some(node) = get_channel_node(channel_id).await? {
let _ = voice_client.remove_user(&node, user_id, channel_id).await;
}
pub fn from_channel(channel: &Channel) -> Self {
Self {
id: channel.id().to_string(),
server_id: channel.server().map(ToString::to_string),
}
}
let channel = Reference::from_unchecked(channel_id).as_channel(db).await?;
delete_voice_state(channel_id, channel.server(), user_id).await?;
Ok(())
}
impl Display for UserVoiceChannel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.id)?;
pub async fn delete_voice_channel(voice_client: &VoiceClient, channel_id: &str, server_id: Option<&str>) -> Result<()> {
if let Some(users) = get_voice_channel_members(channel_id).await? {
let node = get_channel_node(channel_id).await?.unwrap();
if let Some(server_id) = &self.server_id {
f.write_char('-')?;
f.write_str(server_id)?
};
voice_client.delete_room(&node, channel_id).await?;
Ok(())
}
}
delete_channel_voice_state(channel_id, server_id, &users).await?;
};
impl ToRedisArgs for UserVoiceChannel {
fn write_redis_args<W: ?Sized + RedisWrite>(&self, out: &mut W) {
out.write_arg_fmt(self);
}
}
impl FromRedisValue for UserVoiceChannel {
fn from_redis_value(v: &Value) -> Result<Self, RedisError> {
String::from_redis_value(v).map(UserVoiceChannel::from_string)
}
}
Ok(())
}

View File

@@ -1,6 +1,5 @@
use crate::{
models::{Channel, User},
voice::RoomMetadata,
Database,
};
use livekit_api::{
@@ -103,16 +102,11 @@ impl VoiceClient {
pub async fn create_room(&self, node: &str, channel: &Channel) -> Result<Room> {
let room = self.get_node(node)?;
let metadata = RoomMetadata {
server: channel.server().map(|id| id.to_string()),
};
room.client
.create_room(
channel.id(),
CreateRoomOptions {
empty_timeout: 5 * 60, // 5 minutes,
metadata: serde_json::to_string(&metadata).to_internal_error()?,
..Default::default()
},
)

View File

@@ -6,4 +6,4 @@ This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.stoat.chat/faq/
https://developers.stoat.chat/faq.html

View File

@@ -4,6 +4,6 @@ Please navigate to: {{url}}
This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd.
This email has no association with Revolt or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.stoat.chat/faq/
https://developers.revolt.chat/faq.html

View File

@@ -107,7 +107,7 @@
<p class="" style="line-height: 24px; font-size: 16px; width: 100%; margin: 0;" align="left">
Further violations may result in a permanent ban depending on
severity, please abide by the
<a href="https://stoat.chat/legal/community-guidelines" style="color: #0d6efd;">Community Guidelines</a>.
<a href="https://revolt.chat/aup" style="color: #0d6efd;">Acceptable Usage Policy</a>.
</p>
<table class="s-4 w-full" role="presentation" border="0" cellpadding="0" cellspacing="0" style="width: 100%;" width="100%">
<tbody>

View File

@@ -3,7 +3,7 @@ Your account has been suspended, for one or more reasons:
You will be able to use your account again in {{duration}} days.
Further violations may result in a permanent ban depending on severity, please abide by the Community Guidelines (https://stoat.chat/legal/community-guidelines).
Further violations may result in a permanent ban depending on severity, please abide by the Acceptable Usage Policy (https://revolt.chat/aup).
Ban evasion is prohibited and will be dealt with accordingly.

View File

@@ -3,6 +3,6 @@ Your account has been suspended, for one or more reasons:
This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd.
This email has no association with Revolt or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.stoat.chat/faq/
https://developers.revolt.chat/faq.html

View File

@@ -5,6 +5,6 @@ Please navigate to: {{url}}
This email is intended for {{email}}
This email has no association with Stoat or Revolt Platforms Ltd.
This email has no association with Revolt or Revolt Platforms Ltd.
Learn more about third party instances here:
https://developers.stoat.chat/faq/
https://developers.revolt.chat/faq.html

View File

@@ -1,48 +1,38 @@
[package]
name = "revolt-files"
version = "0.12.1"
version = "0.8.9"
edition = "2021"
license = "AGPL-3.0-or-later"
authors = ["Paul Makles <me@insrt.uk>"]
description = "Revolt Backend: S3 and encryption subroutines"
repository = "https://github.com/stoatchat/stoatchat"
[dependencies]
anyhow = { workspace = true }
thiserror = { workspace = true }
tracing = "0.1"
tracing = { workspace = true }
ffprobe = "0.4.0"
imagesize = "0.13.0"
tempfile = "3.12.0"
tokio = { workspace = true }
async-trait = { workspace = true }
base64 = "0.22.1"
aes-gcm = "0.10.3"
typenum = "1.17.0"
ffprobe = { workspace = true }
imagesize = { workspace = true }
tempfile = { workspace = true }
aws-config = "1.5.5"
aws-sdk-s3 = { version = "1.46.0", features = ["behavior-version-latest"] }
base64 = { workspace = true }
aes-gcm = { workspace = true }
typenum = { workspace = true }
aws-config = { workspace = true }
aws-sdk-s3 = { workspace = true, features = ["behavior-version-latest"] }
revolt-config = { workspace = true, features = [
revolt-config = { version = "0.8.9", path = "../config", features = [
"report-macros",
] }
revolt-result = { workspace = true }
revolt-result = { version = "0.8.9", path = "../result" }
# image processing
jxl-oxide = { workspace = true, features = ["image"] }
image = { workspace = true }
jxl-oxide = "0.8.1"
image = { version = "0.25.2" }
# svg rendering
usvg = { workspace = true }
resvg = { workspace = true }
tiny-skia = { workspace = true }
usvg = "0.44.0"
resvg = "0.44.0"
tiny-skia = "0.11.4"
# encoding
webp = { workspace = true }
[dev-dependencies]
uuid = { workspace = true, features = ["v4"] }
webp = "0.3.0"

View File

@@ -1,75 +0,0 @@
use aes_gcm::{
aead::{Aead, AeadCore, AeadMutInPlace, OsRng},
Aes256Gcm, Key, KeyInit, Nonce,
};
use base64::{prelude::BASE64_STANDARD, Engine};
use crate::EncryptionRepository;
pub struct EncryptionKey {
key: String,
}
impl EncryptionKey {
pub async fn from_config() -> EncryptionKey {
EncryptionKey::new(revolt_config::config().await.files.encryption_key)
}
pub fn new(key: String) -> EncryptionKey {
EncryptionKey { key }
}
fn create_cipher(&self) -> Aes256Gcm {
let key = &BASE64_STANDARD
.decode(self.key.clone())
.expect("valid base64 string")[..];
let key: &Key<Aes256Gcm> = key.into();
Aes256Gcm::new(key)
}
}
impl EncryptionRepository for EncryptionKey {
fn decrypt_buffer(&self, mut buf: Vec<u8>, iv: &str) -> anyhow::Result<Vec<u8>> {
let iv = &BASE64_STANDARD.decode(iv).unwrap()[..];
let iv: &Nonce<typenum::consts::U12> = iv.into();
self.create_cipher()
.decrypt_in_place(iv, b"", &mut buf)
.map_err(|error| {
tracing::error!("{}", error);
anyhow::anyhow!("EncryptionRepository: decryption failed")
})?;
Ok(buf)
}
fn encrypt_buffer(&self, buf: &[u8]) -> anyhow::Result<(Vec<u8>, String)> {
let iv = Aes256Gcm::generate_nonce(&mut OsRng);
let buf = self.create_cipher().encrypt(&iv, buf).map_err(|error| {
tracing::error!("{}", error);
anyhow::anyhow!("EncryptionRepository: encryption failed")
})?;
Ok((buf, BASE64_STANDARD.encode(iv)))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encrypt_and_decrypt() {
let encryption =
EncryptionKey::new("XkbJ8gBzrouQ+15Ri23xCC81+aZE26Z6+gXzglFxOD4=".to_string());
let buf: Vec<u8> = vec![67];
let (ciphertext, iv) = encryption.encrypt_buffer(&buf[..]).unwrap();
assert_eq!(ciphertext.len(), 17);
let plaintext = encryption.decrypt_buffer(ciphertext, &iv).unwrap();
assert_eq!(plaintext.len(), 1);
assert_eq!(plaintext[0], 67);
}
}

View File

@@ -1,395 +0,0 @@
use anyhow::Result;
use image::{AnimationDecoder, DynamicImage, ImageBuffer, ImageReader};
use jxl_oxide::integration::JxlDecoder;
use revolt_config::report_internal_error;
use std::io::{BufRead, Read, Seek};
use tempfile::NamedTempFile;
use tiny_skia::Pixmap;
use crate::{MediaError, MediaRepository};
pub struct MediaImpl {
config: revolt_config::Files,
}
impl MediaImpl {
pub async fn from_config() -> MediaImpl {
MediaImpl {
config: revolt_config::config().await.files,
}
}
pub fn new(config: revolt_config::Files) -> MediaImpl {
MediaImpl { config }
}
}
impl MediaRepository for MediaImpl {
fn image_size(&self, f: &NamedTempFile) -> Option<(usize, usize)> {
if let Ok(size) = imagesize::size(f.path())
.inspect_err(|err| tracing::error!("Failed to generate image size! {err:?}"))
{
Some((size.width, size.height))
} else {
None
}
}
fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option<bool> {
match mime {
// Current behaviour is to assume GIFs are animated, this checks for at least 2 frames
"image/gif" => {
let file = std::fs::File::open(f.path()).ok()?;
let reader = std::io::BufReader::new(file);
let decoder = image::codecs::gif::GifDecoder::new(reader).ok()?;
Some(decoder.into_frames().take(2).count() > 1)
}
"image/png" => {
let file = std::fs::File::open(f.path()).ok()?;
let reader = std::io::BufReader::new(file);
let decoder = image::codecs::png::PngDecoder::new(reader).ok()?;
decoder.is_apng().ok()
}
"image/webp" => {
let file = std::fs::File::open(f.path()).ok()?;
let reader = std::io::BufReader::new(file);
let decoder = image::codecs::webp::WebPDecoder::new(reader).ok()?;
Some(decoder.has_animation())
}
_ => Some(false),
}
}
fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)> {
match mime {
"image/svg+xml" => {
let tree =
report_internal_error!(usvg::Tree::from_data(v, &Default::default())).ok()?;
let size = tree.size();
Some((size.width() as usize, size.height() as usize))
}
_ => {
if let Ok(size) = imagesize::blob_size(v)
.inspect_err(|err| tracing::error!("Failed to generate image size! {err:?}"))
{
Some((size.width, size.height))
} else {
None
}
}
}
}
fn decode_image<R: Read + BufRead + Seek>(
&self,
reader: &mut R,
mime: &str,
) -> Result<DynamicImage, MediaError> {
match mime {
"image/jxl" => {
let decoder =
JxlDecoder::new(reader).map_err(|e| MediaError::from(anyhow::anyhow!(e)))?;
DynamicImage::from_decoder(decoder)
.map_err(|e| MediaError::from(anyhow::anyhow!(e)))
}
"image/svg+xml" => {
let mut buf = Vec::new();
reader
.read_to_end(&mut buf)
.map_err(|e| MediaError::from(anyhow::anyhow!(e)))?;
let tree: usvg::Tree = usvg::Tree::from_data(&buf, &Default::default())
.map_err(|e| MediaError::from(anyhow::anyhow!(e)))?;
let size = tree.size();
let mut pixmap = Pixmap::new(size.width() as u32, size.height() as u32)
.ok_or_else(|| MediaError::ImageProcessingFailed {
cause: "failed to create Pixmap, likely zero sized".to_string(),
})?;
let mut pixmap_mut = pixmap.as_mut();
resvg::render(&tree, Default::default(), &mut pixmap_mut);
Ok(DynamicImage::ImageRgba8(
ImageBuffer::from_vec(
size.width() as u32,
size.height() as u32,
pixmap.data().to_vec(),
)
.ok_or_else(|| MediaError::ImageProcessingFailed {
cause: "buffer is not big enough".to_string(),
})?,
))
}
_ => {
let image: ImageReader<&mut R> = image::ImageReader::new(reader)
.with_guessed_format()
.map_err(|e| MediaError::from(anyhow::anyhow!(e)))?;
let image: Result<DynamicImage, MediaError> = image
.decode()
.map_err(|e| MediaError::from(anyhow::anyhow!(e)));
image
}
}
}
fn is_valid_image<R: Read + BufRead + Seek>(&self, reader: &mut R, mime: &str) -> bool {
match mime {
"image/jxl" => jxl_oxide::JxlImage::builder()
.read(reader)
.inspect_err(|err| tracing::error!("Failed to read JXL! {err:?}"))
.is_ok(),
_ => !matches!(
image::ImageReader::new(reader)
.with_guessed_format()
.inspect_err(|err| tracing::error!("Failed to read image! {err:?}"))
.map(|f| f.decode()),
Err(_) | Ok(Err(_))
),
}
}
fn create_thumbnail(&self, image: DynamicImage, tag: &str) -> Vec<u8> {
let [w, h] = self.config.preview.get(tag).unwrap();
let image = image.thumbnail(image.width().min(*w as u32), image.height().min(*h as u32));
let image = match image {
DynamicImage::ImageRgb8(_) => image,
DynamicImage::ImageRgba8(_) => image,
_ => {
if image.has_alpha() {
image.to_rgba8().into()
} else {
image.to_rgb8().into()
}
}
};
let encoder = webp::Encoder::from_image(&image).expect("Could not create encoder.");
if self.config.webp_quality != 100.0 {
encoder.encode(self.config.webp_quality).to_vec()
} else {
encoder.encode_lossless().to_vec()
}
}
fn video_size(&self, f: &NamedTempFile) -> Option<(i64, i64)> {
if let Ok(data) = ffprobe::ffprobe(f.path())
.inspect_err(|err| tracing::error!("Failed to ffprobe file! {err:?}"))
{
for stream in data.streams {
if let (Some(w), Some(h)) = (stream.width, stream.height) {
return Some((w, h));
}
}
None
} else {
None
}
}
}
#[cfg(test)]
mod tests {
use crate::{MediaImpl, MediaRepository};
use std::io::{Cursor, Write};
use tempfile::NamedTempFile;
#[tokio::test]
async fn asset_test_jpeg() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/test.jpeg");
assert_eq!(media.image_size_vec(buf, "image/jpeg"), Some((655, 582)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/jpeg").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_jpeg_is_not_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/test.jpeg"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/jpeg"), Some(false));
}
#[tokio::test]
async fn asset_test_jpeg_extra_bytes() {
let media = MediaImpl::from_config().await;
let buf = [
&include_bytes!("../../tests/assets/test.jpeg")[..],
&[0u8; 16],
]
.concat();
assert_eq!(media.image_size_vec(&buf, "image/jpeg"), Some((655, 582)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/jpeg").unwrap();
media.create_thumbnail(image, "emojis");
}
#[tokio::test]
async fn asset_test_png() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/test.png");
assert_eq!(media.image_size_vec(buf, "image/png"), Some((900, 900)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/png").unwrap();
media.create_thumbnail(image, "emojis");
}
#[tokio::test]
async fn asset_test_png_is_not_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/test.png"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/png"), Some(false));
}
#[tokio::test]
async fn asset_test_png_extra_bytes() {
let media = MediaImpl::from_config().await;
let buf = [
&include_bytes!("../../tests/assets/test.png")[..],
&[0u8; 16],
]
.concat();
assert_eq!(media.image_size_vec(&buf, "image/png"), Some((900, 900)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/png").unwrap();
media.create_thumbnail(image, "emojis");
}
#[tokio::test]
async fn asset_test_floating_point_png() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/test-float.png");
assert_eq!(media.image_size_vec(buf, "image/png"), Some((300, 300)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/png").unwrap();
media.create_thumbnail(image, "avatars");
}
#[tokio::test]
async fn asset_test_corrupted_png() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/corrupted.png");
assert_eq!(media.image_size_vec(buf, "image/png"), Some((900, 900)));
let mut reader = Cursor::new(buf);
media.decode_image(&mut reader, "image/png").unwrap_err();
}
#[tokio::test]
async fn asset_test_animated_png() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/anim-icos.apng");
assert_eq!(media.image_size_vec(buf, "image/png"), Some((128, 128)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/png").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_animated_png_is_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/anim-icos.apng"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/png"), Some(true));
}
#[tokio::test]
async fn asset_test_jxl() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/dice.jxl");
assert_eq!(media.image_size_vec(buf, "image/jxl"), Some((800, 600)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/jxl").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_animated_jxl() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/anim-icos.jxl");
assert_eq!(media.image_size_vec(buf, "image/jxl"), Some((128, 128)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/jxl").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_webp() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/dice.webp");
assert_eq!(media.image_size_vec(buf, "image/webp"), Some((800, 600)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/webp").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_webp_is_not_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/dice.webp"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/webp"), Some(false));
}
#[tokio::test]
async fn asset_test_animated_webp() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/anim-icos.webp");
assert_eq!(media.image_size_vec(buf, "image/webp"), Some((128, 128)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/webp").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_animated_webp_is_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/anim-icos.webp"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/webp"), Some(true));
}
#[tokio::test]
async fn asset_test_animated_gif() {
let media = MediaImpl::from_config().await;
let buf = include_bytes!("../../tests/assets/anim-icos.gif");
assert_eq!(media.image_size_vec(buf, "image/gif"), Some((128, 128)));
let mut reader = Cursor::new(buf);
let image = media.decode_image(&mut reader, "image/gif").unwrap();
media.create_thumbnail(image, "attachments");
}
#[tokio::test]
async fn asset_test_animated_gif_is_animated() {
let media = MediaImpl::from_config().await;
let mut f = NamedTempFile::new().unwrap();
f.write_all(include_bytes!("../../tests/assets/anim-icos.gif"))
.unwrap();
assert_eq!(media.is_animated(&f, "image/gif"), Some(true));
}
}

View File

@@ -1,7 +0,0 @@
mod encryption_impl;
mod media_impl;
mod s3_impl;
pub use encryption_impl::EncryptionKey;
pub use media_impl::MediaImpl;
pub use s3_impl::S3Storage;

View File

@@ -1,118 +0,0 @@
use std::io::Write;
use anyhow::Context;
use aws_sdk_s3::{
config::{Credentials, Region},
Client, Config,
};
use revolt_config::FilesS3;
use crate::{EncryptionRepository, FileStorageRepository};
pub struct S3Storage<ER: EncryptionRepository> {
client: Client,
encryption: ER,
}
impl<ER: EncryptionRepository> S3Storage<ER> {
pub async fn from_config(encryption: ER) -> S3Storage<ER> {
S3Storage::new(encryption, revolt_config::config().await.files.s3)
}
pub fn new(encryption: ER, s3_config: FilesS3) -> S3Storage<ER> {
let provider_name = "my-creds";
let creds = Credentials::new(
s3_config.access_key_id,
s3_config.secret_access_key,
None,
None,
provider_name,
);
let config = Config::builder()
.region(Region::new(s3_config.region))
.endpoint_url(s3_config.endpoint)
.force_path_style(s3_config.path_style_buckets)
.credentials_provider(creds)
.build();
S3Storage {
client: Client::from_conf(config),
encryption,
}
}
}
#[async_trait::async_trait]
impl<ER: EncryptionRepository> FileStorageRepository for S3Storage<ER> {
async fn create_bucket(&self, bucket_id: &str) -> anyhow::Result<()> {
self.client
.create_bucket()
.bucket(bucket_id)
.send()
.await
.with_context(|| format!("failed to create bucket {bucket_id}"))?;
Ok(())
}
async fn fetch_and_decrypt_file(
&self,
bucket_id: &str,
path: &str,
iv: &str,
) -> anyhow::Result<Vec<u8>> {
let mut object = self
.client
.get_object()
.bucket(bucket_id)
.key(path)
.send()
.await
.with_context(|| format!("failed to get object at {path} in {bucket_id}"))?;
let mut buf = vec![];
while let Some(bytes) = object.body.next().await {
let data = bytes?;
buf.write_all(&data)?;
}
if iv.is_empty() {
Ok(buf)
} else {
self.encryption.decrypt_buffer(buf, iv)
}
}
async fn encrypt_and_upload_file(
&self,
bucket_id: &str,
path: &str,
buf: &[u8],
) -> anyhow::Result<String> {
let (buf, iv) = self.encryption.encrypt_buffer(buf)?;
self.client
.put_object()
.bucket(bucket_id)
.key(path)
.body(buf.into())
.send()
.await
.with_context(|| format!("failed to put object at {path} in {bucket_id}"))?;
Ok(iv)
}
async fn delete_file(&self, bucket_id: &str, path: &str) -> anyhow::Result<()> {
self.client
.delete_object()
.bucket(bucket_id)
.key(path)
.send()
.await
.with_context(|| format!("failed to delete object at {path} in {bucket_id}"))?;
Ok(())
}
}

View File

@@ -1,210 +1,293 @@
mod implementation;
mod repositories;
use std::io::{BufRead, Read, Seek, Write};
pub use implementation::*;
pub use repositories::*;
use aes_gcm::{
aead::{AeadCore, AeadMutInPlace, OsRng},
Aes256Gcm, Key, KeyInit, Nonce,
};
use image::{DynamicImage, ImageBuffer};
use revolt_config::{config, report_internal_error, FilesS3};
use revolt_result::{create_error, Result};
use std::io::{BufRead, Read, Seek};
use image::DynamicImage;
use revolt_config::{report_internal_error, Files, FilesLimit, FilesS3};
use revolt_result::Result;
use aws_sdk_s3::{
config::{Credentials, Region},
Client, Config,
};
use base64::prelude::*;
use tempfile::NamedTempFile;
use tiny_skia::Pixmap;
/// Size of the authentication tag in the buffer
pub const AUTHENTICATION_TAG_SIZE_BYTES: usize = 16;
/// Create an S3 client
pub fn create_client(s3_config: FilesS3) -> Client {
let provider_name = "my-creds";
let creds = Credentials::new(
s3_config.access_key_id,
s3_config.secret_access_key,
None,
None,
provider_name,
);
let config = Config::builder()
.region(Region::new(s3_config.region))
.endpoint_url(s3_config.endpoint)
.force_path_style(s3_config.path_style_buckets)
.credentials_provider(creds)
.build();
Client::from_conf(config)
}
/// Create an AES-256-GCM cipher
pub fn create_cipher(key: &str) -> Aes256Gcm {
let key = &BASE64_STANDARD.decode(key).expect("valid base64 string")[..];
let key: &Key<Aes256Gcm> = key.into();
Aes256Gcm::new(key)
}
/// Fetch a file from S3 (and decrypt it)
pub async fn fetch_from_s3(bucket_id: &str, path: &str, iv: &str) -> Result<Vec<u8>> {
let encryption = implementation::EncryptionKey::from_config().await;
let storage = implementation::S3Storage::from_config(encryption).await;
report_internal_error!(storage.fetch_and_decrypt_file(bucket_id, path, iv).await)
pub async fn fetch_from_s3(bucket_id: &str, path: &str, nonce: &str) -> Result<Vec<u8>> {
let config = config().await;
let client = create_client(config.files.s3);
// Send a request for the file
let mut obj =
report_internal_error!(client.get_object().bucket(bucket_id).key(path).send().await)?;
// Read the file from remote
let mut buf = vec![];
while let Some(bytes) = obj.body.next().await {
let data = report_internal_error!(bytes)?;
report_internal_error!(buf.write_all(&data))?;
// is there a more efficient way to do this?
// we just want the Vec<u8>
}
// File is not encrypted
if nonce.is_empty() {
return Ok(buf);
}
// Recover nonce as bytes
let nonce = &BASE64_STANDARD.decode(nonce).unwrap()[..];
let nonce: &Nonce<typenum::consts::U12> = nonce.into();
// Decrypt the file
create_cipher(&config.files.encryption_key)
.decrypt_in_place(nonce, b"", &mut buf)
.map_err(|_| create_error!(InternalError))?;
// Remove the authentication tag bytes that were added during encryption
buf.truncate(buf.len() - AUTHENTICATION_TAG_SIZE_BYTES);
Ok(buf)
}
/// Encrypt and upload a file to S3 (returning its nonce/IV)
pub async fn upload_to_s3(bucket_id: &str, path: &str, buf: &[u8]) -> Result<String> {
let encryption = implementation::EncryptionKey::from_config().await;
let storage = implementation::S3Storage::from_config(encryption).await;
report_internal_error!(storage.encrypt_and_upload_file(bucket_id, path, buf).await)
let config = config().await;
let client = create_client(config.files.s3);
// Generate a nonce
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
// Extend the buffer for in-place encryption
let mut buf = [buf, &[0; AUTHENTICATION_TAG_SIZE_BYTES]].concat();
// Encrypt the file in place
create_cipher(&config.files.encryption_key)
.encrypt_in_place(&nonce, b"", &mut buf)
.map_err(|_| create_error!(InternalError))?;
// Upload the file to remote
report_internal_error!(
client
.put_object()
.bucket(bucket_id)
.key(path)
.body(buf.into())
.send()
.await
)?;
Ok(BASE64_STANDARD.encode(nonce))
}
/// Delete a file from S3 by path
pub async fn delete_from_s3(bucket_id: &str, path: &str) -> Result<()> {
let encryption = implementation::EncryptionKey::from_config().await;
let storage = implementation::S3Storage::from_config(encryption).await;
report_internal_error!(storage.delete_file(bucket_id, path).await)
let config = config().await;
let client = create_client(config.files.s3);
report_internal_error!(
client
.delete_object()
.bucket(bucket_id)
.key(path)
.send()
.await
)?;
Ok(())
}
/// Determine size of image at temp file
pub fn image_size(f: &NamedTempFile) -> Option<(usize, usize)> {
let media = MediaImpl::new(Files {
blocked_mime_types: Default::default(),
clamd_host: Default::default(),
encryption_key: Default::default(),
limit: FilesLimit {
max_mega_pixels: 0,
max_pixel_side: 0,
min_file_size: 0,
min_resolution: [0, 0],
},
preview: Default::default(),
s3: FilesS3 {
access_key_id: Default::default(),
default_bucket: Default::default(),
endpoint: Default::default(),
path_style_buckets: Default::default(),
region: Default::default(),
secret_access_key: Default::default(),
},
scan_mime_types: Default::default(),
webp_quality: Default::default(),
});
media.image_size(f)
if let Ok(size) = imagesize::size(f.path())
.inspect_err(|err| tracing::error!("Failed to generate image size! {err:?}"))
{
Some((size.width, size.height))
} else {
None
}
}
/// Determine size of image with buffer
pub fn image_size_vec(v: &[u8], mime: &str) -> Option<(usize, usize)> {
let media = MediaImpl::new(Files {
blocked_mime_types: Default::default(),
clamd_host: Default::default(),
encryption_key: Default::default(),
limit: FilesLimit {
max_mega_pixels: 0,
max_pixel_side: 0,
min_file_size: 0,
min_resolution: [0, 0],
},
preview: Default::default(),
s3: FilesS3 {
access_key_id: Default::default(),
default_bucket: Default::default(),
endpoint: Default::default(),
path_style_buckets: Default::default(),
region: Default::default(),
secret_access_key: Default::default(),
},
scan_mime_types: Default::default(),
webp_quality: Default::default(),
});
match mime {
"image/svg+xml" => {
let tree =
report_internal_error!(usvg::Tree::from_data(v, &Default::default())).ok()?;
media.image_size_vec(v, mime)
}
/// Check whether an image file contains animation data
pub fn is_animated(f: &NamedTempFile, mime: &str) -> Option<bool> {
let media = MediaImpl::new(Files {
blocked_mime_types: Default::default(),
clamd_host: Default::default(),
encryption_key: Default::default(),
limit: FilesLimit {
max_mega_pixels: 0,
max_pixel_side: 0,
min_file_size: 0,
min_resolution: [0, 0],
},
preview: Default::default(),
s3: FilesS3 {
access_key_id: Default::default(),
default_bucket: Default::default(),
endpoint: Default::default(),
path_style_buckets: Default::default(),
region: Default::default(),
secret_access_key: Default::default(),
},
scan_mime_types: Default::default(),
webp_quality: Default::default(),
});
media.is_animated(f, mime)
let size = tree.size();
Some((size.width() as usize, size.height() as usize))
}
_ => {
if let Ok(size) = imagesize::blob_size(v)
.inspect_err(|err| tracing::error!("Failed to generate image size! {err:?}"))
{
Some((size.width, size.height))
} else {
None
}
}
}
}
/// Determine size of video at temp file
pub fn video_size(f: &NamedTempFile) -> Option<(i64, i64)> {
let media = MediaImpl::new(Files {
blocked_mime_types: Default::default(),
clamd_host: Default::default(),
encryption_key: Default::default(),
limit: FilesLimit {
max_mega_pixels: 0,
max_pixel_side: 0,
min_file_size: 0,
min_resolution: [0, 0],
},
preview: Default::default(),
s3: FilesS3 {
access_key_id: Default::default(),
default_bucket: Default::default(),
endpoint: Default::default(),
path_style_buckets: Default::default(),
region: Default::default(),
secret_access_key: Default::default(),
},
scan_mime_types: Default::default(),
webp_quality: Default::default(),
});
if let Ok(data) = ffprobe::ffprobe(f.path())
.inspect_err(|err| tracing::error!("Failed to ffprobe file! {err:?}"))
{
// Use first valid stream
for stream in data.streams {
if let (Some(w), Some(h)) = (stream.width, stream.height) {
return Some((w, h));
}
}
media.video_size(f)
None
} else {
None
}
}
/// Decode image from reader
pub fn decode_image<R: Read + BufRead + Seek>(reader: &mut R, mime: &str) -> Result<DynamicImage> {
let media = MediaImpl::new(Files {
blocked_mime_types: Default::default(),
clamd_host: Default::default(),
encryption_key: Default::default(),
limit: FilesLimit {
max_mega_pixels: 0,
max_pixel_side: 0,
min_file_size: 0,
min_resolution: [0, 0],
},
preview: Default::default(),
s3: FilesS3 {
access_key_id: Default::default(),
default_bucket: Default::default(),
endpoint: Default::default(),
path_style_buckets: Default::default(),
region: Default::default(),
secret_access_key: Default::default(),
},
scan_mime_types: Default::default(),
webp_quality: Default::default(),
});
match mime {
// Read image using jxl-oxide crate
"image/jxl" => {
let jxl_image = report_internal_error!(jxl_oxide::JxlImage::builder().read(reader))?;
if let Ok(frame) = jxl_image.render_frame(0) {
match frame.color_channels().len() {
3 => Ok(DynamicImage::ImageRgb8(
DynamicImage::ImageRgb32F(
ImageBuffer::from_vec(
jxl_image.width(),
jxl_image.height(),
frame.image().buf().to_vec(),
)
.ok_or_else(|| create_error!(ImageProcessingFailed))?,
)
.to_rgb8(),
)),
4 => Ok(DynamicImage::ImageRgba8(
DynamicImage::ImageRgba32F(
ImageBuffer::from_vec(
jxl_image.width(),
jxl_image.height(),
frame.image().buf().to_vec(),
)
.ok_or_else(|| create_error!(ImageProcessingFailed))?,
)
.to_rgba8(),
)),
_ => Err(create_error!(ImageProcessingFailed)),
}
} else {
Err(create_error!(ImageProcessingFailed))
}
}
// Read image using resvg
"image/svg+xml" => {
// usvg doesn't support Read trait so copy to buffer
let mut buf = Vec::new();
report_internal_error!(reader.read_to_end(&mut buf))?;
report_internal_error!(media.decode_image(reader, mime))
let tree = report_internal_error!(usvg::Tree::from_data(&buf, &Default::default()))?;
let size = tree.size();
let mut pixmap = Pixmap::new(size.width() as u32, size.height() as u32)
.ok_or_else(|| create_error!(ImageProcessingFailed))?;
let mut pixmap_mut = pixmap.as_mut();
resvg::render(&tree, Default::default(), &mut pixmap_mut);
Ok(DynamicImage::ImageRgba8(
ImageBuffer::from_vec(
size.width() as u32,
size.height() as u32,
pixmap.data().to_vec(),
)
.ok_or_else(|| create_error!(ImageProcessingFailed))?,
))
}
// Check if we can read using image-rs crate
_ => report_internal_error!(report_internal_error!(
image::ImageReader::new(reader).with_guessed_format()
)?
.decode()),
}
}
/// Check whether given reader has a valid image
pub fn is_valid_image<R: Read + BufRead + Seek>(reader: &mut R, mime: &str) -> bool {
let media = MediaImpl::new(Files {
blocked_mime_types: Default::default(),
clamd_host: Default::default(),
encryption_key: Default::default(),
limit: FilesLimit {
max_mega_pixels: 0,
max_pixel_side: 0,
min_file_size: 0,
min_resolution: [0, 0],
},
preview: Default::default(),
s3: FilesS3 {
access_key_id: Default::default(),
default_bucket: Default::default(),
endpoint: Default::default(),
path_style_buckets: Default::default(),
region: Default::default(),
secret_access_key: Default::default(),
},
scan_mime_types: Default::default(),
webp_quality: Default::default(),
});
media.is_valid_image(reader, mime)
match mime {
// Check if we can read using jxl-oxide crate
"image/jxl" => jxl_oxide::JxlImage::builder()
.read(reader)
.inspect_err(|err| tracing::error!("Failed to read JXL! {err:?}"))
.is_ok(),
// Check if we can read using image-rs crate
_ => !matches!(
image::ImageReader::new(reader)
.with_guessed_format()
.inspect_err(|err| tracing::error!("Failed to read image! {err:?}"))
.map(|f| f.decode()),
Err(_) | Ok(Err(_))
),
}
}
/// Create thumbnail from given image
pub async fn create_thumbnail(image: DynamicImage, tag: &str) -> Vec<u8> {
let media = MediaImpl::from_config().await;
media.create_thumbnail(image, tag)
// Load configuration
let config = config().await;
let [w, h] = config.files.preview.get(tag).unwrap();
// Create thumbnail
//.resize(width as u32, height as u32, image::imageops::FilterType::Gaussian)
// resize is about 2.5x slower,
// thumbnail doesn't have terrible quality
// so we use thumbnail
let image = image.thumbnail(image.width().min(*w as u32), image.height().min(*h as u32));
// Encode it into WEBP
let encoder = webp::Encoder::from_image(&image).expect("Could not create encoder.");
if config.files.webp_quality != 100.0 {
encoder.encode(config.files.webp_quality).to_vec()
} else {
encoder.encode_lossless().to_vec()
}
}

View File

@@ -1,6 +0,0 @@
use anyhow::Result;
pub trait EncryptionRepository: Send + Sync + 'static {
fn decrypt_buffer(&self, buf: Vec<u8>, iv: &str) -> anyhow::Result<Vec<u8>>;
fn encrypt_buffer(&self, buf: &[u8]) -> Result<(Vec<u8>, String)>;
}

View File

@@ -1,22 +0,0 @@
use anyhow::Result;
#[async_trait::async_trait]
pub trait FileStorageRepository: Send + Sync + 'static {
async fn create_bucket(&self, bucket_id: &str) -> anyhow::Result<()>;
async fn fetch_and_decrypt_file(
&self,
bucket_id: &str,
path: &str,
iv: &str,
) -> Result<Vec<u8>>;
async fn encrypt_and_upload_file(
&self,
bucket_id: &str,
path: &str,
buf: &[u8],
) -> Result<String>;
async fn delete_file(&self, bucket_id: &str, path: &str) -> Result<()>;
}

View File

@@ -1,33 +0,0 @@
use anyhow::Result;
use image::DynamicImage;
use std::io::{BufRead, Read, Seek};
use tempfile::NamedTempFile;
use thiserror::Error;
pub trait MediaRepository: Send + Sync + 'static {
fn image_size(&self, f: &NamedTempFile) -> Option<(usize, usize)>;
fn is_animated(&self, f: &NamedTempFile, mime: &str) -> Option<bool>;
fn image_size_vec(&self, v: &[u8], mime: &str) -> Option<(usize, usize)>;
fn decode_image<R: Read + BufRead + Seek>(
&self,
reader: &mut R,
mime: &str,
) -> Result<DynamicImage, MediaError>;
fn is_valid_image<R: Read + BufRead + Seek>(&self, reader: &mut R, mime: &str) -> bool;
fn create_thumbnail(&self, image: DynamicImage, tag: &str) -> Vec<u8>;
fn video_size(&self, f: &NamedTempFile) -> Option<(i64, i64)>;
}
#[derive(Debug, Error)]
pub enum MediaError {
#[error("image processing failed because {cause}")]
ImageProcessingFailed { cause: String },
#[error(transparent)]
Unknown(#[from] anyhow::Error),
}

View File

@@ -1,7 +0,0 @@
mod encryption_repository;
mod file_storage_repository;
mod media_repository;
pub use encryption_repository::EncryptionRepository;
pub use file_storage_repository::FileStorageRepository;
pub use media_repository::{MediaError, MediaRepository};

Binary file not shown.

Before

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 375 KiB

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