Initial commit: split VNOX monorepo into VNOX-Server
Unified server binary (vnox-serverd) combining: - Gateway (TCP, auth, channels, sessions, SQLite) - Voice-node (UDP relay, Opus, jitter buffer) Standalone gateway (vnox-gateway) and voice-node (vnox-voice-node) preserved as sub-crates.
This commit is contained in:
commit
e751e0bf5b
94 changed files with 9771 additions and 0 deletions
29
.cargo/config.toml
Normal file
29
.cargo/config.toml
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
[env]
|
||||||
|
# audiopus_sys builds opus from source via CMake.
|
||||||
|
# CMake 4.x removed compatibility with cmake_minimum_required < 3.5.
|
||||||
|
# This flag tells CMake to accept the old policy version.
|
||||||
|
CMAKE_POLICY_VERSION_MINIMUM = "3.5"
|
||||||
|
# Cap linker+rustc memory pressure so builds don't OOM-kill the host.
|
||||||
|
RUST_MIN_STACK = "8388608"
|
||||||
|
|
||||||
|
[build]
|
||||||
|
# Limit parallelism so GPUI's giant dep tree doesn't saturate all cores.
|
||||||
|
# 4 jobs is a safe-ish ceiling for a 15GB / 24-thread box.
|
||||||
|
jobs = 6
|
||||||
|
|
||||||
|
[profile.dev]
|
||||||
|
# Less debug info = less RAM per codegen unit, faster cold builds.
|
||||||
|
debug = 1
|
||||||
|
# More codegen units = smaller per-unit memory footprint.
|
||||||
|
codegen-units = 16
|
||||||
|
# Don't keep intermediate artifacts for every dep.
|
||||||
|
split-debuginfo = "unpacked"
|
||||||
|
|
||||||
|
[profile.dev.package."*"]
|
||||||
|
# Deps don't need debuginfo at all → big savings on big trees like GPUI.
|
||||||
|
debug = 0
|
||||||
|
codegen-units = 16
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
debug = 0
|
||||||
|
codegen-units = 16
|
||||||
32
.github/.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
32
.github/.github/ISSUE_TEMPLATE/bug.md
vendored
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
---
|
||||||
|
name: Bug report
|
||||||
|
about: Something is broken
|
||||||
|
labels: bug
|
||||||
|
---
|
||||||
|
|
||||||
|
## What happened
|
||||||
|
|
||||||
|
<!-- Describe the bug clearly -->
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
|
||||||
|
<!-- What should have happened -->
|
||||||
|
|
||||||
|
## Steps to reproduce
|
||||||
|
|
||||||
|
1.
|
||||||
|
2.
|
||||||
|
3.
|
||||||
|
|
||||||
|
## Environment
|
||||||
|
|
||||||
|
- OS:
|
||||||
|
- VNOX version:
|
||||||
|
- LNEx version:
|
||||||
|
- Install method: Docker / systemd / binary / built from source
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
```
|
||||||
|
paste relevant log lines here (VNOX_LOG=debug)
|
||||||
|
```
|
||||||
25
.github/.github/ISSUE_TEMPLATE/protocol.md
vendored
Normal file
25
.github/.github/ISSUE_TEMPLATE/protocol.md
vendored
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
---
|
||||||
|
name: Protocol change proposal
|
||||||
|
about: Propose a change to LNEx protocol
|
||||||
|
labels: protocol
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
<!-- One paragraph: what changes and why -->
|
||||||
|
|
||||||
|
## Motivation
|
||||||
|
|
||||||
|
<!-- What problem does this solve? -->
|
||||||
|
|
||||||
|
## Proposed change
|
||||||
|
|
||||||
|
<!-- Be specific: packet format, new packet type, behavior change -->
|
||||||
|
|
||||||
|
## Backward compatibility
|
||||||
|
|
||||||
|
<!-- Is this a breaking change? How should old clients/servers behave? -->
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
<!-- What else did you consider? -->
|
||||||
68
.github/.github/repo-metadata.md
vendored
Normal file
68
.github/.github/repo-metadata.md
vendored
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
# Vnox Repository Metadata
|
||||||
|
|
||||||
|
## Description
|
||||||
|
Self-hosted realtime voice and chat platform. Decentralized, lightweight, and moddable. Built on LNEx, a custom protocol for low-latency federated communication. Not Discord. Not TeamSpeak. Not cloud.
|
||||||
|
|
||||||
|
## Topics / Tags
|
||||||
|
- communication
|
||||||
|
- voice-chat
|
||||||
|
- rust
|
||||||
|
- self-hosted
|
||||||
|
- decentralized
|
||||||
|
- real-time
|
||||||
|
- low-latency
|
||||||
|
- protocol
|
||||||
|
- federated
|
||||||
|
- lightweight
|
||||||
|
- moddable
|
||||||
|
- open-source
|
||||||
|
- p2p
|
||||||
|
- networking
|
||||||
|
- gateway
|
||||||
|
- voice-relay
|
||||||
|
- chat
|
||||||
|
- lnex
|
||||||
|
|
||||||
|
## Short Descriptions
|
||||||
|
|
||||||
|
### Primary (for GitHub repo description)
|
||||||
|
Self-hosted realtime voice and chat on a custom low-latency protocol. Decentralized, lightweight, and extensible.
|
||||||
|
|
||||||
|
### Secondary Options
|
||||||
|
- Decentralized alternative to Discord for self-hosted deployment
|
||||||
|
- Low-latency federated communication platform with custom LNEx protocol
|
||||||
|
- Open-source voice and chat server written in Rust
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
- Self-hosted deployment
|
||||||
|
- Decentralized architecture
|
||||||
|
- Real-time voice and text communication
|
||||||
|
- Low-latency protocol (LNEx)
|
||||||
|
- Lightweight footprint
|
||||||
|
- Extensible/moddable design
|
||||||
|
- Federation support (Phase 3 roadmap)
|
||||||
|
- Desktop client with egui UI
|
||||||
|
- SQLite/PostgreSQL backend support
|
||||||
|
|
||||||
|
## Technology Stack
|
||||||
|
- **Language**: Rust (99.6%)
|
||||||
|
- **Protocol**: LNEx (custom)
|
||||||
|
- **Audio**: Opus codec
|
||||||
|
- **Database**: SQLite (with PostgreSQL support planned)
|
||||||
|
- **UI**: egui (desktop client)
|
||||||
|
- **Transport**: TCP (gateway), UDP (voice relay)
|
||||||
|
|
||||||
|
## Current Status
|
||||||
|
- Phase 1: Implemented, not production-ready
|
||||||
|
- Gateway: ✅ TCP listener, LNEx handshake, channels, chat, SQLite
|
||||||
|
- Voice node: ✅ UDP relay, voice packet routing
|
||||||
|
- Desktop client: ✅ egui UI, net layer, audio pipeline (partial)
|
||||||
|
- LNEx protocol: ✅ Specified and implemented
|
||||||
|
- Federation: 🔄 Planned (Phase 3)
|
||||||
|
- Mobile client: 🔄 Planned (Phase 3)
|
||||||
|
|
||||||
|
⚠️ **Security Warning**: Traffic in v0.1.x is unencrypted plaintext. Do not use in production.
|
||||||
|
|
||||||
|
## License
|
||||||
|
- **Code**: GPL-3.0
|
||||||
|
- **Protocol**: CC0 (Public Domain)
|
||||||
47
.github/.github/workflows/ci.yml
vendored
Normal file
47
.github/.github/workflows/ci.yml
vendored
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, dev]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
|
||||||
|
env:
|
||||||
|
CARGO_TERM_COLOR: always
|
||||||
|
RUST_BACKTRACE: 1
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check:
|
||||||
|
name: Check
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install Rust stable
|
||||||
|
uses: dtolnay/rust-toolchain@stable
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Install system deps
|
||||||
|
run: sudo apt-get install -y libasound2-dev libopus-dev pkg-config
|
||||||
|
|
||||||
|
- name: Cache cargo
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: |
|
||||||
|
~/.cargo/registry
|
||||||
|
~/.cargo/git
|
||||||
|
target
|
||||||
|
key: ${{ runner.os }}-cargo-${{ hashFiles('**/Cargo.lock') }}
|
||||||
|
|
||||||
|
- name: cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: cargo clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
|
- name: cargo build
|
||||||
|
run: cargo build --all
|
||||||
|
|
||||||
|
- name: cargo test
|
||||||
|
run: cargo test --workspace
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
target/
|
||||||
|
dev/data/
|
||||||
|
*.db
|
||||||
|
.DS_Store
|
||||||
2545
Cargo.lock
generated
Normal file
2545
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
35
Cargo.toml
Normal file
35
Cargo.toml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"gateway",
|
||||||
|
"voice-node",
|
||||||
|
"serverd",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
license = "GPL-3.0"
|
||||||
|
authors = ["VNOX Contributors"]
|
||||||
|
repository = "https://github.com/loki5512344/Vnox"
|
||||||
|
rust-version = "1.85"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
tokio = { version = "1.52", features = ["full"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
ed25519-dalek = { version = "2", features = ["rand_core"] }
|
||||||
|
x25519-dalek = { version = "2", features = ["static_secrets"] }
|
||||||
|
chacha20poly1305 = "0.10"
|
||||||
|
argon2 = "0.5"
|
||||||
|
rand = "0.8"
|
||||||
|
opus = "0.3"
|
||||||
|
uuid = { version = "1", features = ["v4"] }
|
||||||
|
hex = "0.4"
|
||||||
|
hkdf = "0.13"
|
||||||
|
sha2 = "0.11"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
anyhow = "1"
|
||||||
|
thiserror = "2"
|
||||||
|
toml = "1.1"
|
||||||
25
Dockerfile
Normal file
25
Dockerfile
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
|
||||||
|
FROM rust:1.85-slim-bookworm AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config libssl-dev libopus-dev cmake && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cargo build --release --package vnox-gateway
|
||||||
|
|
||||||
|
# ─── Stage 2: Runtime ─────────────────────────────────────────────────────────
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends ca-certificates && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/vnox-gateway /usr/local/bin/vnox-gateway
|
||||||
|
|
||||||
|
EXPOSE 7600/tcp
|
||||||
|
|
||||||
|
ENTRYPOINT ["vnox-gateway"]
|
||||||
21
Dockerfile.voice-node
Normal file
21
Dockerfile.voice-node
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# ─── Stage 1: Build ───────────────────────────────────────────────────────────
|
||||||
|
FROM rust:1.85-slim-bookworm AS builder
|
||||||
|
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends \
|
||||||
|
pkg-config libssl-dev libopus-dev cmake && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN cargo build --release --package vnox-voice-node
|
||||||
|
|
||||||
|
# ─── Stage 2: Runtime ─────────────────────────────────────────────────────────
|
||||||
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
|
COPY --from=builder /build/target/release/vnox-voice-node /usr/local/bin/vnox-voice-node
|
||||||
|
|
||||||
|
EXPOSE 7700/udp
|
||||||
|
|
||||||
|
ENTRYPOINT ["vnox-voice-node"]
|
||||||
82
dev/dev/README.md
Normal file
82
dev/dev/README.md
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
# Local development configuration
|
||||||
|
|
||||||
|
This directory holds the default config for running VNOX on your machine.
|
||||||
|
|
||||||
|
## File: `config.toml`
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[node]
|
||||||
|
name = "dev-node"
|
||||||
|
address = "127.0.0.1"
|
||||||
|
|
||||||
|
[gateway]
|
||||||
|
bind = "127.0.0.1:7600"
|
||||||
|
max_connections = 100
|
||||||
|
session_timeout = 600
|
||||||
|
|
||||||
|
[voice]
|
||||||
|
bind = "127.0.0.1:7700"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
data_dir = "./dev/data"
|
||||||
|
backend = "sqlite"
|
||||||
|
sqlite_path = "./dev/data/vnox.db"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Sections
|
||||||
|
|
||||||
|
**`[node]`**
|
||||||
|
|
||||||
|
- `name` - shown to clients after connect (HELLO / session UI).
|
||||||
|
- `address` - public node address for future federation; local dev uses loopback.
|
||||||
|
|
||||||
|
**`[gateway]`**
|
||||||
|
|
||||||
|
- `bind` - TCP listen address for client connections. Default dev port: `7600`.
|
||||||
|
- `max_connections` - connection cap (not enforced in all code paths yet).
|
||||||
|
- `session_timeout` - session lifetime in seconds; sent to clients as `expires_at`.
|
||||||
|
|
||||||
|
**`[voice]`**
|
||||||
|
|
||||||
|
- `bind` - UDP listen address for voice relay. Default dev port: `7700`.
|
||||||
|
- Sent to clients as `voice_endpoint` when they join a channel.
|
||||||
|
|
||||||
|
**`[storage]`**
|
||||||
|
|
||||||
|
- `data_dir` - gateway data directory; stores SQLite DB and `server_identity.json`.
|
||||||
|
- `sqlite_path` - SQLite database file for chat history and user records.
|
||||||
|
|
||||||
|
## First run
|
||||||
|
|
||||||
|
```sh
|
||||||
|
mkdir -p dev/data
|
||||||
|
|
||||||
|
cargo run -p vnox-gateway -- --config dev/config.toml
|
||||||
|
cargo run -p vnox-voice-node -- --config dev/config.toml
|
||||||
|
cargo run -p vnox-client
|
||||||
|
```
|
||||||
|
|
||||||
|
On first gateway start, `dev/data/server_identity.json` is created automatically.
|
||||||
|
Back it up if you care about a stable server identity across reinstalls.
|
||||||
|
|
||||||
|
## E2E voice test
|
||||||
|
|
||||||
|
With gateway and voice-node running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run -p vnox-client --bin vnox-e2e-voice
|
||||||
|
```
|
||||||
|
|
||||||
|
This headless test connects two TCP clients, joins the `voice` channel, sends UDP packets, and verifies the voice-node relays between them. Exit code 0 means pass.
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
Set log level via environment variable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VNOX_LOG=debug cargo run -p vnox-gateway -- --config dev/config.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Full reference
|
||||||
|
|
||||||
|
Production-oriented options: [docs/03-server/configuration.md](../docs/03-server/configuration.md).
|
||||||
25
dev/dev/config.toml
Normal file
25
dev/dev/config.toml
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
[node]
|
||||||
|
name = "dev-node"
|
||||||
|
address = "127.0.0.1"
|
||||||
|
|
||||||
|
[gateway]
|
||||||
|
bind = "127.0.0.1:7600"
|
||||||
|
max_connections = 100
|
||||||
|
session_timeout = 600
|
||||||
|
# Admin HTTP server (/health, /metrics, /version). Default 0.0.0.0:7601.
|
||||||
|
admin_bind = "127.0.0.1:7601"
|
||||||
|
# Per-session chat/DM rate limit (messages per second; 0 to disable).
|
||||||
|
message_rate_per_sec = 5
|
||||||
|
# Burst size for the token bucket.
|
||||||
|
message_rate_burst = 10
|
||||||
|
|
||||||
|
[voice]
|
||||||
|
bind = "127.0.0.1:7700"
|
||||||
|
|
||||||
|
[storage]
|
||||||
|
data_dir = "./dev/data"
|
||||||
|
backend = "sqlite"
|
||||||
|
sqlite_path = "./dev/data/vnox.db"
|
||||||
|
|
||||||
|
[server]
|
||||||
|
mode = "private"
|
||||||
24
docker-compose.yml
Normal file
24
docker-compose.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
services:
|
||||||
|
gateway:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
network_mode: "host"
|
||||||
|
volumes:
|
||||||
|
- ./dev/config.toml:/etc/vnox/config.toml:ro
|
||||||
|
- vnox-data:/var/lib/vnox
|
||||||
|
command: ["--config", "/etc/vnox/config.toml"]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
voice-node:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.voice-node
|
||||||
|
network_mode: "host"
|
||||||
|
depends_on:
|
||||||
|
- gateway
|
||||||
|
command: ["--config", "/etc/vnox/config.toml"]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
vnox-data:
|
||||||
40
gateway/Cargo.toml
Normal file
40
gateway/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
[package]
|
||||||
|
name = "vnox-gateway"
|
||||||
|
description = "VNOX gateway — auth, channels, sessions, permissions"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "vnox_gateway"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "vnox-gateway"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
|
serde_json.workspace = true
|
||||||
|
ed25519-dalek.workspace = true
|
||||||
|
x25519-dalek.workspace = true
|
||||||
|
chacha20poly1305.workspace = true
|
||||||
|
hkdf.workspace = true
|
||||||
|
sha2.workspace = true
|
||||||
|
argon2.workspace = true
|
||||||
|
rand.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
hex.workspace = true
|
||||||
|
|
||||||
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
|
tower = "0.5"
|
||||||
|
sqlx = { version = "0.9", features = ["sqlite", "runtime-tokio", "tls-rustls"] }
|
||||||
|
bitflags = "2"
|
||||||
130
gateway/src/admin/metrics.rs
Normal file
130
gateway/src/admin/metrics.rs
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
//! Prometheus-compatible metrics counters.
|
||||||
|
//!
|
||||||
|
//! All counters are atomic and lock-free. The text format output follows the
|
||||||
|
//! Prometheus exposition format v0.0.4 so it can be scraped directly.
|
||||||
|
|
||||||
|
use std::sync::{
|
||||||
|
Arc,
|
||||||
|
atomic::{AtomicU64, Ordering},
|
||||||
|
};
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::admin::AdminState;
|
||||||
|
|
||||||
|
/// Container for all gateway metrics. Cheap to clone (Arc-backed).
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct Metrics {
|
||||||
|
pub messages_sent: AtomicU64,
|
||||||
|
pub dm_messages_sent: AtomicU64,
|
||||||
|
pub voice_packets_relayed: AtomicU64,
|
||||||
|
pub connections_total: AtomicU64,
|
||||||
|
pub auth_failures: AtomicU64,
|
||||||
|
pub rate_limited_events: AtomicU64,
|
||||||
|
pub errors_total: AtomicU64,
|
||||||
|
pub guilds_total: AtomicU64,
|
||||||
|
pub friends_requests_total: AtomicU64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Metrics {
|
||||||
|
pub fn new() -> Arc<Self> {
|
||||||
|
Arc::new(Self::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inc(&self, field: &AtomicU64) {
|
||||||
|
field.fetch_add(1, Ordering::Relaxed);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render metrics in Prometheus text exposition format.
|
||||||
|
pub fn render_prometheus(&self, s: &AdminState) -> String {
|
||||||
|
let uptime = s.started_at.elapsed().as_secs();
|
||||||
|
let sessions = s.sessions_count.load(Ordering::Relaxed);
|
||||||
|
let channels = s.channels_count.load(Ordering::Relaxed);
|
||||||
|
let mut out = String::with_capacity(2048);
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_uptime_seconds Gateway uptime in seconds.\n");
|
||||||
|
out.push_str("# TYPE vnox_uptime_seconds counter\n");
|
||||||
|
out.push_str(&format!("vnox_uptime_seconds {uptime}\n\n"));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_sessions_active Currently active sessions.\n");
|
||||||
|
out.push_str("# TYPE vnox_sessions_active gauge\n");
|
||||||
|
out.push_str(&format!("vnox_sessions_active {sessions}\n\n"));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_channels_active Currently active channels.\n");
|
||||||
|
out.push_str("# TYPE vnox_channels_active gauge\n");
|
||||||
|
out.push_str(&format!("vnox_channels_active {channels}\n\n"));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_messages_sent_total Total chat messages sent.\n");
|
||||||
|
out.push_str("# TYPE vnox_messages_sent_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_messages_sent_total {}\n\n",
|
||||||
|
self.messages_sent.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_dm_messages_sent_total Total DM messages sent.\n");
|
||||||
|
out.push_str("# TYPE vnox_dm_messages_sent_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_dm_messages_sent_total {}\n\n",
|
||||||
|
self.dm_messages_sent.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_voice_packets_relayed_total Total UDP voice packets relayed.\n");
|
||||||
|
out.push_str("# TYPE vnox_voice_packets_relayed_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_voice_packets_relayed_total {}\n\n",
|
||||||
|
self.voice_packets_relayed.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_connections_total Total TCP connections accepted.\n");
|
||||||
|
out.push_str("# TYPE vnox_connections_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_connections_total {}\n\n",
|
||||||
|
self.connections_total.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_auth_failures_total Total authentication failures.\n");
|
||||||
|
out.push_str("# TYPE vnox_auth_failures_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_auth_failures_total {}\n\n",
|
||||||
|
self.auth_failures.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_rate_limited_events_total Total rate-limited requests.\n");
|
||||||
|
out.push_str("# TYPE vnox_rate_limited_events_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_rate_limited_events_total {}\n\n",
|
||||||
|
self.rate_limited_events.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_errors_total Total error packets sent.\n");
|
||||||
|
out.push_str("# TYPE vnox_errors_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_errors_total {}\n\n",
|
||||||
|
self.errors_total.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_guilds_total Total guilds tracked.\n");
|
||||||
|
out.push_str("# TYPE vnox_guilds_total gauge\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_guilds_total {}\n\n",
|
||||||
|
self.guilds_total.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out.push_str("# HELP vnox_friends_requests_total Total friend requests sent.\n");
|
||||||
|
out.push_str("# TYPE vnox_friends_requests_total counter\n");
|
||||||
|
out.push_str(&format!(
|
||||||
|
"vnox_friends_requests_total {}\n\n",
|
||||||
|
self.friends_requests_total.load(Ordering::Relaxed)
|
||||||
|
));
|
||||||
|
|
||||||
|
out
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to bump a metric from anywhere that holds an Arc<Metrics>.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub fn bump(m: &Arc<Metrics>, field: fn(&Metrics) -> &AtomicU64) {
|
||||||
|
m.inc(field(m));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
fn _silence_unused_helper(_start: Instant) {}
|
||||||
77
gateway/src/admin/mod.rs
Normal file
77
gateway/src/admin/mod.rs
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
//! HTTP admin server — exposes `/health` and `/metrics` endpoints.
|
||||||
|
//!
|
||||||
|
//! Runs on a separate TCP port (default 7601) so it does not interfere
|
||||||
|
//! with the LNEx control plane on 7600. All endpoints are unauthenticated
|
||||||
|
//! and intended for orchestrators (k8s liveness probes, Prometheus scrapers).
|
||||||
|
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use axum::{Router, extract::State, http::StatusCode, response::IntoResponse, routing::get};
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tracing::{error, info};
|
||||||
|
|
||||||
|
use crate::admin::metrics::Metrics;
|
||||||
|
|
||||||
|
pub mod metrics;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct AdminState {
|
||||||
|
pub started_at: Instant,
|
||||||
|
pub node_name: String,
|
||||||
|
pub node_address: String,
|
||||||
|
pub private_mode: bool,
|
||||||
|
pub metrics: Arc<Metrics>,
|
||||||
|
pub sessions_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
pub channels_count: std::sync::Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run(bind: String, state: AdminState) {
|
||||||
|
let app = Router::new()
|
||||||
|
.route("/health", get(health))
|
||||||
|
.route("/metrics", get(metrics))
|
||||||
|
.route("/version", get(version))
|
||||||
|
.with_state(state);
|
||||||
|
|
||||||
|
let listener = match TcpListener::bind(&bind).await {
|
||||||
|
Ok(l) => l,
|
||||||
|
Err(e) => {
|
||||||
|
error!("admin server failed to bind {bind}: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
info!("admin HTTP listening on {bind}");
|
||||||
|
if let Err(e) = axum::serve(listener, app).await {
|
||||||
|
error!("admin server: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn health(State(s): State<AdminState>) -> impl IntoResponse {
|
||||||
|
let uptime = s.started_at.elapsed().as_secs();
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"status": "ok",
|
||||||
|
"uptime_seconds": uptime,
|
||||||
|
"node": s.node_name,
|
||||||
|
"address": s.node_address,
|
||||||
|
"private_mode": s.private_mode,
|
||||||
|
});
|
||||||
|
(StatusCode::OK, axum::Json(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn version(State(_s): State<AdminState>) -> impl IntoResponse {
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"name": "vnox-gateway",
|
||||||
|
"version": env!("CARGO_PKG_VERSION"),
|
||||||
|
"lnex_version": "1",
|
||||||
|
});
|
||||||
|
(StatusCode::OK, axum::Json(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn metrics(State(s): State<AdminState>) -> impl IntoResponse {
|
||||||
|
let body = s.metrics.render_prometheus(&s);
|
||||||
|
(
|
||||||
|
StatusCode::OK,
|
||||||
|
[("content-type", "text/plain; version=0.0.4")],
|
||||||
|
body,
|
||||||
|
)
|
||||||
|
}
|
||||||
1
gateway/src/bootstrap/mod.rs
Normal file
1
gateway/src/bootstrap/mod.rs
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
pub mod server_identity;
|
||||||
58
gateway/src/bootstrap/server_identity.rs
Normal file
58
gateway/src/bootstrap/server_identity.rs
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use ed25519_dalek::SigningKey;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct StoredIdentity {
|
||||||
|
pubkey_hex: String,
|
||||||
|
privkey_hex: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Gateway Ed25519 identity — loaded from disk or generated on first start.
|
||||||
|
pub struct ServerIdentity {
|
||||||
|
signing_key: SigningKey,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerIdentity {
|
||||||
|
pub fn pubkey_hex(&self) -> String {
|
||||||
|
hex::encode(self.signing_key.verifying_key().to_bytes())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_or_generate(data_dir: &Path) -> Result<Self> {
|
||||||
|
let path = data_dir.join("server_identity.json");
|
||||||
|
|
||||||
|
if path.exists() {
|
||||||
|
let text = std::fs::read_to_string(&path)?;
|
||||||
|
let stored: StoredIdentity = serde_json::from_str(&text)?;
|
||||||
|
let signing_key = SigningKey::from_bytes(
|
||||||
|
hex::decode(&stored.privkey_hex)?
|
||||||
|
.as_slice()
|
||||||
|
.try_into()
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid server private key length"))?,
|
||||||
|
);
|
||||||
|
let computed_pubkey = hex::encode(signing_key.verifying_key().to_bytes());
|
||||||
|
if stored.pubkey_hex != computed_pubkey {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"server_identity.json pubkey does not match private key"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return Ok(Self { signing_key });
|
||||||
|
}
|
||||||
|
|
||||||
|
let signing_key = SigningKey::generate(&mut OsRng);
|
||||||
|
let verifying_key = signing_key.verifying_key();
|
||||||
|
let pubkey_hex = hex::encode(verifying_key.to_bytes());
|
||||||
|
|
||||||
|
let stored = StoredIdentity {
|
||||||
|
pubkey_hex,
|
||||||
|
privkey_hex: hex::encode(signing_key.to_bytes()),
|
||||||
|
};
|
||||||
|
|
||||||
|
std::fs::create_dir_all(data_dir)?;
|
||||||
|
std::fs::write(&path, serde_json::to_string_pretty(&stored)?)?;
|
||||||
|
|
||||||
|
Ok(Self { signing_key })
|
||||||
|
}
|
||||||
|
}
|
||||||
45
gateway/src/domain/auth.rs
Normal file
45
gateway/src/domain/auth.rs
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use ed25519_dalek::{Signature, VerifyingKey};
|
||||||
|
|
||||||
|
/// Verify Ed25519 signature from AUTH packet.
|
||||||
|
pub fn verify_auth(challenge: &[u8; 32], pubkey: &[u8; 32], signature: &[u8; 64]) -> Result<()> {
|
||||||
|
let key = VerifyingKey::from_bytes(pubkey).map_err(|_| anyhow::anyhow!("invalid pubkey"))?;
|
||||||
|
let sig = Signature::from_bytes(signature);
|
||||||
|
key.verify_strict(challenge, &sig)
|
||||||
|
.map_err(|_| anyhow::anyhow!("invalid signature"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Random 32-byte challenge nonce for HELLO.
|
||||||
|
pub fn new_challenge() -> [u8; 32] {
|
||||||
|
use rand::RngCore;
|
||||||
|
let mut n = [0u8; 32];
|
||||||
|
rand::thread_rng().fill_bytes(&mut n);
|
||||||
|
n
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use ed25519_dalek::{Signer, SigningKey};
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_auth_accepts_valid_signature() {
|
||||||
|
let sk = SigningKey::generate(&mut OsRng);
|
||||||
|
let vk = sk.verifying_key();
|
||||||
|
let challenge = [7u8; 32];
|
||||||
|
let sig = sk.sign(&challenge);
|
||||||
|
verify_auth(&challenge, vk.as_bytes(), &sig.to_bytes()).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn verify_auth_rejects_tampered_challenge() {
|
||||||
|
let sk = SigningKey::generate(&mut OsRng);
|
||||||
|
let vk = sk.verifying_key();
|
||||||
|
let challenge = [7u8; 32];
|
||||||
|
let sig = sk.sign(&challenge);
|
||||||
|
let mut other = challenge;
|
||||||
|
other[0] ^= 1;
|
||||||
|
assert!(verify_auth(&other, vk.as_bytes(), &sig.to_bytes()).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
55
gateway/src/domain/channels/mod.rs
Normal file
55
gateway/src/domain/channels/mod.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
pub mod ops;
|
||||||
|
|
||||||
|
pub use ops::{create, delete, get_channel, join, leave, list, members};
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub enum ChannelKind {
|
||||||
|
Text,
|
||||||
|
Voice,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ChannelKind {
|
||||||
|
pub fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Text => "text",
|
||||||
|
Self::Voice => "voice",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Channel {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub kind: ChannelKind,
|
||||||
|
pub members: HashSet<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ChannelStore = Arc<RwLock<HashMap<String, Channel>>>;
|
||||||
|
|
||||||
|
pub fn new_store() -> ChannelStore {
|
||||||
|
let mut m = HashMap::new();
|
||||||
|
m.insert(
|
||||||
|
"general".into(),
|
||||||
|
Channel {
|
||||||
|
id: "general".into(),
|
||||||
|
name: "general".into(),
|
||||||
|
kind: ChannelKind::Text,
|
||||||
|
members: HashSet::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
m.insert(
|
||||||
|
"voice".into(),
|
||||||
|
Channel {
|
||||||
|
id: "voice".into(),
|
||||||
|
name: "voice".into(),
|
||||||
|
kind: ChannelKind::Voice,
|
||||||
|
members: HashSet::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
Arc::new(RwLock::new(m))
|
||||||
|
}
|
||||||
64
gateway/src/domain/channels/ops.rs
Normal file
64
gateway/src/domain/channels/ops.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
use crate::domain::channels::{Channel, ChannelKind, ChannelStore};
|
||||||
|
|
||||||
|
pub async fn join(store: &ChannelStore, channel_id: &str, session_id: &str) -> bool {
|
||||||
|
let mut l = store.write().await;
|
||||||
|
if let Some(ch) = l.get_mut(channel_id) {
|
||||||
|
ch.members.insert(session_id.into());
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn leave(store: &ChannelStore, channel_id: &str, session_id: &str) {
|
||||||
|
if let Some(ch) = store.write().await.get_mut(channel_id) {
|
||||||
|
ch.members.remove(session_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn members(store: &ChannelStore, channel_id: &str) -> Vec<String> {
|
||||||
|
store
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.get(channel_id)
|
||||||
|
.map(|ch| ch.members.iter().cloned().collect())
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_channel(store: &ChannelStore, channel_id: &str) -> Option<Channel> {
|
||||||
|
store.read().await.get(channel_id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a new channel in the store. Returns `false` if a channel with the
|
||||||
|
/// same id already exists.
|
||||||
|
pub async fn create(
|
||||||
|
store: &ChannelStore,
|
||||||
|
channel_id: &str,
|
||||||
|
channel_name: &str,
|
||||||
|
kind: ChannelKind,
|
||||||
|
) -> bool {
|
||||||
|
let mut l = store.write().await;
|
||||||
|
if l.contains_key(channel_id) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
l.insert(
|
||||||
|
channel_id.to_string(),
|
||||||
|
Channel {
|
||||||
|
id: channel_id.to_string(),
|
||||||
|
name: channel_name.to_string(),
|
||||||
|
kind,
|
||||||
|
members: std::collections::HashSet::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a channel from the store. Returns `true` if it existed.
|
||||||
|
pub async fn delete(store: &ChannelStore, channel_id: &str) -> bool {
|
||||||
|
store.write().await.remove(channel_id).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all channels in the store.
|
||||||
|
pub async fn list(store: &ChannelStore) -> Vec<Channel> {
|
||||||
|
store.read().await.values().cloned().collect()
|
||||||
|
}
|
||||||
118
gateway/src/domain/config.rs
Normal file
118
gateway/src/domain/config.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub node: NodeConfig,
|
||||||
|
pub gateway: GatewayConfig,
|
||||||
|
pub voice: VoiceConfig,
|
||||||
|
pub storage: StorageConfig,
|
||||||
|
#[serde(default)]
|
||||||
|
pub server: ServerConfig,
|
||||||
|
pub federation: Option<FederationConfig>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Returns `true` when private mode is active — federation disabled,
|
||||||
|
/// server is isolated, client shows a "PRIVATE" badge.
|
||||||
|
pub fn is_private(&self) -> bool {
|
||||||
|
match self.server.mode {
|
||||||
|
Some(ref m) => m.is_private(),
|
||||||
|
None => self
|
||||||
|
.federation
|
||||||
|
.as_ref()
|
||||||
|
.map(|f| f.is_private())
|
||||||
|
.unwrap_or(true),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Default)]
|
||||||
|
pub struct ServerConfig {
|
||||||
|
/// Explicit server mode: "private" or "federated".
|
||||||
|
/// Overrides `[federation] enabled` when set.
|
||||||
|
pub mode: Option<ServerMode>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Server isolation mode.
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum ServerMode {
|
||||||
|
/// Fully isolated — no federation, no node discovery.
|
||||||
|
Private,
|
||||||
|
/// Can discover, peer, and sync with other VNOX nodes.
|
||||||
|
Federated,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ServerMode {
|
||||||
|
pub fn is_private(&self) -> bool {
|
||||||
|
matches!(self, ServerMode::Private)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct FederationConfig {
|
||||||
|
pub enabled: bool,
|
||||||
|
/// If true, the server runs in private mode (federation disabled, client shows a badge).
|
||||||
|
/// Defaults to the inverse of `enabled` when not set.
|
||||||
|
pub private_mode: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FederationConfig {
|
||||||
|
/// Returns `true` when private mode is active — federation is disabled and the
|
||||||
|
/// server does not exchange any data with other nodes.
|
||||||
|
pub fn is_private(&self) -> bool {
|
||||||
|
self.private_mode.unwrap_or(!self.enabled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct NodeConfig {
|
||||||
|
pub name: String,
|
||||||
|
pub address: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct GatewayConfig {
|
||||||
|
pub bind: String,
|
||||||
|
pub max_connections: Option<usize>,
|
||||||
|
pub session_timeout: Option<u64>,
|
||||||
|
/// Bind address for the admin HTTP server (/health, /metrics).
|
||||||
|
/// Defaults to "0.0.0.0:7601" when not specified.
|
||||||
|
#[serde(default)]
|
||||||
|
pub admin_bind: Option<String>,
|
||||||
|
/// Per-session chat/DM message rate limit (messages per second).
|
||||||
|
/// Defaults to 5.0 when not specified. Set to 0 to disable.
|
||||||
|
#[serde(default)]
|
||||||
|
pub message_rate_per_sec: Option<f32>,
|
||||||
|
/// Burst size for the token bucket (messages allowed in one tick).
|
||||||
|
/// Defaults to 10.
|
||||||
|
#[serde(default)]
|
||||||
|
pub message_rate_burst: Option<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct VoiceConfig {
|
||||||
|
pub bind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct StorageConfig {
|
||||||
|
pub data_dir: PathBuf,
|
||||||
|
pub backend: Option<String>,
|
||||||
|
pub sqlite_path: Option<PathBuf>,
|
||||||
|
pub postgres_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Result<Config> {
|
||||||
|
let path = std::env::args()
|
||||||
|
.skip_while(|a| a != "--config")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "/etc/vnox/config.toml".into());
|
||||||
|
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?;
|
||||||
|
|
||||||
|
toml::from_str(&text).map_err(|e| anyhow::anyhow!("invalid config: {e}"))
|
||||||
|
}
|
||||||
7
gateway/src/domain/mod.rs
Normal file
7
gateway/src/domain/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
pub mod auth;
|
||||||
|
pub mod channels;
|
||||||
|
pub mod config;
|
||||||
|
pub mod permissions;
|
||||||
|
pub mod rate_limit;
|
||||||
|
pub mod session;
|
||||||
|
pub mod storage;
|
||||||
33
gateway/src/domain/permissions.rs
Normal file
33
gateway/src/domain/permissions.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
use bitflags::bitflags;
|
||||||
|
|
||||||
|
bitflags! {
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
pub struct Permissions: u64 {
|
||||||
|
const CREATE_INVITE = 1 << 0;
|
||||||
|
const KICK_MEMBERS = 1 << 1;
|
||||||
|
const BAN_MEMBERS = 1 << 2;
|
||||||
|
const MANAGE_CHANNELS = 1 << 3;
|
||||||
|
const MANAGE_ROLES = 1 << 4;
|
||||||
|
const MANAGE_GUILD = 1 << 5;
|
||||||
|
const MESSAGE_SEND = 1 << 6;
|
||||||
|
const MESSAGE_MANAGE = 1 << 7;
|
||||||
|
const VOICE_SPEAK = 1 << 8;
|
||||||
|
const VOICE_MUTE = 1 << 9;
|
||||||
|
const VOICE_DEAFEN = 1 << 10;
|
||||||
|
const ADMINISTRATOR = 1 << 63;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Permissions {
|
||||||
|
pub fn has(&self, required: Permissions) -> bool {
|
||||||
|
self.contains(required) || self.contains(Permissions::ADMINISTRATOR)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_role_perms(perms: &[u64]) -> Permissions {
|
||||||
|
let mut combined = Permissions::empty();
|
||||||
|
for &p in perms {
|
||||||
|
combined |= Permissions::from_bits_truncate(p);
|
||||||
|
}
|
||||||
|
combined
|
||||||
|
}
|
||||||
|
}
|
||||||
117
gateway/src/domain/rate_limit/mod.rs
Normal file
117
gateway/src/domain/rate_limit/mod.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
//! Per-session token-bucket rate limiter.
|
||||||
|
//!
|
||||||
|
//! Each authenticated session gets its own bucket. The bucket refills at
|
||||||
|
//! `rate_per_sec` tokens per second, up to a maximum of `burst` tokens.
|
||||||
|
//! A message consumes 1 token; if the bucket is empty, the message is
|
||||||
|
//! rejected with `ErrorCode::RateLimited`.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
use crate::domain::session::SessionStore;
|
||||||
|
|
||||||
|
pub struct RateLimiter {
|
||||||
|
inner: Mutex<HashMap<String, Bucket>>,
|
||||||
|
rate_per_sec: f32,
|
||||||
|
burst: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Bucket {
|
||||||
|
tokens: f32,
|
||||||
|
last_refill: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RateLimiter {
|
||||||
|
pub fn new(rate_per_sec: f32, burst: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
inner: Mutex::new(HashMap::new()),
|
||||||
|
rate_per_sec,
|
||||||
|
burst,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try to consume one token. Returns `true` if allowed, `false` if rate-limited.
|
||||||
|
/// Lazily creates a bucket for new sessions.
|
||||||
|
pub fn try_consume(&self, session_id: &str) -> bool {
|
||||||
|
if self.rate_per_sec <= 0.0 {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let mut guard = self.inner.lock().unwrap();
|
||||||
|
let now = Instant::now();
|
||||||
|
let bucket = guard
|
||||||
|
.entry(session_id.to_string())
|
||||||
|
.or_insert_with(|| Bucket {
|
||||||
|
tokens: self.burst as f32,
|
||||||
|
last_refill: now,
|
||||||
|
});
|
||||||
|
// Refill
|
||||||
|
let elapsed = now.duration_since(bucket.last_refill).as_secs_f32();
|
||||||
|
bucket.tokens = (bucket.tokens + elapsed * self.rate_per_sec).min(self.burst as f32);
|
||||||
|
bucket.last_refill = now;
|
||||||
|
if bucket.tokens >= 1.0 {
|
||||||
|
bucket.tokens -= 1.0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a session's bucket on disconnect.
|
||||||
|
pub fn remove(&self, session_id: &str) {
|
||||||
|
let mut guard = self.inner.lock().unwrap();
|
||||||
|
guard.remove(session_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Periodically prune buckets for sessions that no longer exist.
|
||||||
|
/// Returns the number of buckets pruned.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub async fn prune_dead(&self, sessions: &SessionStore) -> usize {
|
||||||
|
let live: Vec<String> = sessions.read().await.keys().cloned().collect();
|
||||||
|
let live_set: std::collections::HashSet<String> = live.into_iter().collect();
|
||||||
|
let mut guard = self.inner.lock().unwrap();
|
||||||
|
let before = guard.len();
|
||||||
|
guard.retain(|sid, _| live_set.contains(sid));
|
||||||
|
before - guard.len()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn allows_within_burst() {
|
||||||
|
let r = RateLimiter::new(10.0, 3);
|
||||||
|
assert!(r.try_consume("s1"));
|
||||||
|
assert!(r.try_consume("s1"));
|
||||||
|
assert!(r.try_consume("s1"));
|
||||||
|
assert!(!r.try_consume("s1")); // bucket empty
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn independent_buckets_per_session() {
|
||||||
|
let r = RateLimiter::new(10.0, 1);
|
||||||
|
assert!(r.try_consume("s1"));
|
||||||
|
assert!(!r.try_consume("s1"));
|
||||||
|
assert!(r.try_consume("s2"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn disabled_when_rate_zero() {
|
||||||
|
let r = RateLimiter::new(0.0, 1);
|
||||||
|
for _ in 0..100 {
|
||||||
|
assert!(r.try_consume("s1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_clears_bucket() {
|
||||||
|
let r = RateLimiter::new(0.1, 1);
|
||||||
|
assert!(r.try_consume("s1"));
|
||||||
|
assert!(!r.try_consume("s1"));
|
||||||
|
r.remove("s1");
|
||||||
|
// After removal, a fresh bucket is created with full burst.
|
||||||
|
assert!(r.try_consume("s1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
48
gateway/src/domain/session.rs
Normal file
48
gateway/src/domain/session.rs
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Session {
|
||||||
|
pub session_id: String,
|
||||||
|
pub token: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub channel_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type SessionStore = Arc<RwLock<HashMap<String, Session>>>;
|
||||||
|
|
||||||
|
pub fn new_store() -> SessionStore {
|
||||||
|
Arc::new(RwLock::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create(store: &SessionStore, user_id: String, nickname: String) -> Session {
|
||||||
|
let s = Session {
|
||||||
|
session_id: Uuid::new_v4().to_string(),
|
||||||
|
token: Uuid::new_v4().to_string(),
|
||||||
|
user_id,
|
||||||
|
nickname,
|
||||||
|
channel_id: None,
|
||||||
|
};
|
||||||
|
store.write().await.insert(s.session_id.clone(), s.clone());
|
||||||
|
s
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get(store: &SessionStore, id: &str) -> Option<Session> {
|
||||||
|
store.read().await.get(id).cloned()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove(store: &SessionStore, id: &str) {
|
||||||
|
store.write().await.remove(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_session_id_by_user_id(store: &SessionStore, user_id: &str) -> Option<String> {
|
||||||
|
store
|
||||||
|
.read()
|
||||||
|
.await
|
||||||
|
.iter()
|
||||||
|
.find(|(_, s)| s.user_id == user_id)
|
||||||
|
.map(|(id, _)| id.clone())
|
||||||
|
}
|
||||||
38
gateway/src/domain/storage/dm_unread.rs
Normal file
38
gateway/src/domain/storage/dm_unread.rs
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl super::Storage {
|
||||||
|
pub async fn increment_dm_unread(&self, dm_id: &str, recipient_id: &str) -> Result<()> {
|
||||||
|
let (u1, _u2) = sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT user1_id,user2_id FROM direct_messages WHERE id=?",
|
||||||
|
)
|
||||||
|
.bind(dm_id)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if recipient_id == u1 {
|
||||||
|
sqlx::query("UPDATE direct_messages SET unread_count_2=unread_count_2+1 WHERE id=?")
|
||||||
|
.bind(dm_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
} else {
|
||||||
|
sqlx::query("UPDATE direct_messages SET unread_count_1=unread_count_1+1 WHERE id=?")
|
||||||
|
.bind(dm_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn reset_dm_unread(&self, dm_id: &str, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE direct_messages SET \
|
||||||
|
unread_count_1 = CASE WHEN user1_id=? THEN 0 ELSE unread_count_1 END, \
|
||||||
|
unread_count_2 = CASE WHEN user2_id=? THEN 0 ELSE unread_count_2 END WHERE id=?",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(dm_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
127
gateway/src/domain/storage/dms.rs
Normal file
127
gateway/src/domain/storage/dms.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::proto::DmMessagePayload;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct DmMsgRow {
|
||||||
|
dm_id: String,
|
||||||
|
sender_id: String,
|
||||||
|
body: String,
|
||||||
|
created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Storage {
|
||||||
|
pub async fn find_or_create_dm(&self, user1: &str, user2: &str) -> Result<(String, i64)> {
|
||||||
|
let (u1, u2) = if user1 < user2 {
|
||||||
|
(user1, user2)
|
||||||
|
} else {
|
||||||
|
(user2, user1)
|
||||||
|
};
|
||||||
|
let existing = sqlx::query_as::<_, (String, i64, i64)>(
|
||||||
|
"SELECT id,unread_count_1,unread_count_2 FROM direct_messages WHERE user1_id=? AND user2_id=?",
|
||||||
|
).bind(u1).bind(u2).fetch_optional(&self.pool).await?;
|
||||||
|
if let Some((id, uc1, uc2)) = existing {
|
||||||
|
return Ok((id, if u1 == user1 { uc1 } else { uc2 }));
|
||||||
|
}
|
||||||
|
let dm_id = format!("dm_{}_{}", u1, u2);
|
||||||
|
let now = super::now_ms();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO direct_messages (id,user1_id,user2_id,created_at,unread_count_1,unread_count_2) VALUES (?,?,?,?,0,0)",
|
||||||
|
).bind(&dm_id).bind(u1).bind(u2).bind(now).execute(&self.pool).await?;
|
||||||
|
Ok((dm_id, 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn save_dm_message(
|
||||||
|
&self,
|
||||||
|
dm_id: &str,
|
||||||
|
sender_id: &str,
|
||||||
|
body: &str,
|
||||||
|
) -> Result<DmMessagePayload> {
|
||||||
|
let msg_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let ts = super::now_ms();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO dm_messages (id,dm_id,sender_id,body,created_at) VALUES (?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(&msg_id)
|
||||||
|
.bind(dm_id)
|
||||||
|
.bind(sender_id)
|
||||||
|
.bind(body)
|
||||||
|
.bind(ts)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("UPDATE direct_messages SET last_message_at=? WHERE id=?")
|
||||||
|
.bind(ts)
|
||||||
|
.bind(dm_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(DmMessagePayload {
|
||||||
|
dm_id: dm_id.to_string(),
|
||||||
|
sender_id: sender_id.to_string(),
|
||||||
|
content: body.to_string(),
|
||||||
|
timestamp: ts,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_dm_messages(
|
||||||
|
&self,
|
||||||
|
dm_id: &str,
|
||||||
|
limit: i64,
|
||||||
|
search_query: Option<&str>,
|
||||||
|
before_timestamp: Option<i64>,
|
||||||
|
) -> Result<Vec<DmMessagePayload>> {
|
||||||
|
let mut sql = String::from(
|
||||||
|
"SELECT dm_id,sender_id,body,created_at FROM \
|
||||||
|
(SELECT * FROM dm_messages WHERE dm_id=? ",
|
||||||
|
);
|
||||||
|
if search_query.is_some() {
|
||||||
|
sql.push_str("AND body LIKE '%' || ? || '%' ");
|
||||||
|
}
|
||||||
|
if before_timestamp.is_some() {
|
||||||
|
sql.push_str("AND created_at < ? ");
|
||||||
|
}
|
||||||
|
sql.push_str("ORDER BY created_at DESC LIMIT ?) ORDER BY created_at ASC");
|
||||||
|
let mut q = sqlx::query_as::<_, DmMsgRow>(sqlx::AssertSqlSafe(sql.as_str()));
|
||||||
|
q = q.bind(dm_id);
|
||||||
|
if let Some(sq) = search_query {
|
||||||
|
q = q.bind(sq);
|
||||||
|
}
|
||||||
|
if let Some(bt) = before_timestamp {
|
||||||
|
q = q.bind(bt);
|
||||||
|
}
|
||||||
|
q = q.bind(limit);
|
||||||
|
let rows = q.fetch_all(&self.pool).await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| DmMessagePayload {
|
||||||
|
dm_id: r.dm_id,
|
||||||
|
sender_id: r.sender_id,
|
||||||
|
content: r.body,
|
||||||
|
timestamp: r.created_at,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_dm_user_id(&self, dm_id: &str, my_id: &str) -> Result<Option<String>> {
|
||||||
|
Ok(sqlx::query_as::<_, (String, String)>(
|
||||||
|
"SELECT user1_id,user2_id FROM direct_messages WHERE id=?",
|
||||||
|
)
|
||||||
|
.bind(dm_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(|(u1, u2)| if u1 == my_id { u2 } else { u1 }))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_dm_nickname(&self, user_id: &str) -> Result<Option<String>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, (String,)>("SELECT nickname FROM users WHERE pubkey=?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(|(n,)| n),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_nickname(&self, user_id: &str) -> Result<Option<String>> {
|
||||||
|
self.get_dm_nickname(user_id).await
|
||||||
|
}
|
||||||
|
}
|
||||||
30
gateway/src/domain/storage/guilds/audit.rs
Normal file
30
gateway/src/domain/storage/guilds/audit.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||||
|
pub struct AuditLogRow {
|
||||||
|
pub id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub actor_id: String,
|
||||||
|
pub action: String,
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub target_id: Option<String>,
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub target_type: Option<String>,
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::super::Storage {
|
||||||
|
/// Fetch the last `limit` audit log entries for a guild, newest first.
|
||||||
|
pub async fn get_audit_log(&self, guild_id: &str, limit: i64) -> Result<Vec<AuditLogRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, AuditLogRow>(
|
||||||
|
"SELECT id, guild_id, actor_id, action, target_id, target_type, reason, created_at \
|
||||||
|
FROM audit_logs WHERE guild_id=? ORDER BY created_at DESC LIMIT ?",
|
||||||
|
)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
210
gateway/src/domain/storage/guilds/mod.rs
Normal file
210
gateway/src/domain/storage/guilds/mod.rs
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
pub mod audit;
|
||||||
|
pub mod roles;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
pub use audit::AuditLogRow;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||||
|
pub struct GuildRow {
|
||||||
|
pub id: String,
|
||||||
|
pub owner_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub member_count: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||||
|
pub struct GuildMemberRow {
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub joined_at: i64,
|
||||||
|
/// Highest role color (or "#ffffff" if none).
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub role_color: String,
|
||||||
|
/// Highest role name (or "member" if none).
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub role_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug)]
|
||||||
|
pub struct InviteRow {
|
||||||
|
pub id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
#[sqlx(default)]
|
||||||
|
pub guild_name: String,
|
||||||
|
pub creator_id: String,
|
||||||
|
pub code: String,
|
||||||
|
pub max_uses: Option<i64>,
|
||||||
|
pub uses: i64,
|
||||||
|
pub expires_at: Option<i64>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Storage {
|
||||||
|
pub async fn create_guild(&self, owner_id: &str, name: &str) -> Result<String> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let now = super::now_ms();
|
||||||
|
sqlx::query("INSERT INTO guilds (id,owner_id,name,created_at) VALUES (?,?,?,?)")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO guild_members (guild_id,user_id,joined_at) VALUES (?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let role_id = uuid::Uuid::new_v4().to_string();
|
||||||
|
sqlx::query("INSERT INTO roles (id,guild_id,name,permissions,position,created_at) VALUES (?,?,?,?,0,?)")
|
||||||
|
.bind(&role_id).bind(&id).bind("@everyone").bind(u64::MAX as i64).bind(now)
|
||||||
|
.execute(&self.pool).await?;
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO member_roles (guild_id,user_id,role_id) VALUES (?,?,?)")
|
||||||
|
.bind(&id)
|
||||||
|
.bind(owner_id)
|
||||||
|
.bind(&role_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_guild(&self, guild_id: &str) -> Result<Option<GuildRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, GuildRow>(
|
||||||
|
"SELECT g.id,g.owner_id,g.name,g.created_at, \
|
||||||
|
(SELECT COUNT(*) FROM guild_members WHERE guild_id=g.id) as member_count \
|
||||||
|
FROM guilds g WHERE g.id=?",
|
||||||
|
)
|
||||||
|
.bind(guild_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_user_guilds(&self, user_id: &str) -> Result<Vec<GuildRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, GuildRow>(
|
||||||
|
"SELECT g.id,g.owner_id,g.name,g.created_at, \
|
||||||
|
(SELECT COUNT(*) FROM guild_members WHERE guild_id=g.id) as member_count \
|
||||||
|
FROM guilds g JOIN guild_members gm ON g.id=gm.guild_id \
|
||||||
|
WHERE gm.user_id=? ORDER BY g.name",
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_guild(&self, guild_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM guild_members WHERE guild_id=?")
|
||||||
|
.bind(guild_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM roles WHERE guild_id=?")
|
||||||
|
.bind(guild_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM invites WHERE guild_id=?")
|
||||||
|
.bind(guild_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM guilds WHERE id=?")
|
||||||
|
.bind(guild_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_guild_member(&self, guild_id: &str, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO guild_members (guild_id,user_id,joined_at) VALUES (?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(super::now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_guild_member(&self, guild_id: &str, user_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM guild_members WHERE guild_id=? AND user_id=?")
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all members of a guild with their highest role (by position).
|
||||||
|
pub async fn list_guild_members(&self, guild_id: &str) -> Result<Vec<GuildMemberRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, GuildMemberRow>(
|
||||||
|
"SELECT gm.user_id, COALESCE(u.nickname, gm.user_id) as nickname, gm.joined_at, \
|
||||||
|
COALESCE((SELECT r.color FROM roles r \
|
||||||
|
JOIN member_roles mr ON r.id=mr.role_id \
|
||||||
|
WHERE mr.guild_id=gm.guild_id AND mr.user_id=gm.user_id \
|
||||||
|
ORDER BY r.position DESC LIMIT 1), '#ffffff') as role_color, \
|
||||||
|
COALESCE((SELECT r.name FROM roles r \
|
||||||
|
JOIN member_roles mr ON r.id=mr.role_id \
|
||||||
|
WHERE mr.guild_id=gm.guild_id AND mr.user_id=gm.user_id \
|
||||||
|
ORDER BY r.position DESC LIMIT 1), 'member') as role_name \
|
||||||
|
FROM guild_members gm LEFT JOIN users u ON gm.user_id=u.pubkey \
|
||||||
|
WHERE gm.guild_id=? ORDER BY gm.joined_at ASC",
|
||||||
|
)
|
||||||
|
.bind(guild_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign a role to a user in a guild (idempotent).
|
||||||
|
pub async fn assign_role(&self, guild_id: &str, user_id: &str, role_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO member_roles (guild_id,user_id,role_id) VALUES (?,?,?)")
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a role from a user in a guild.
|
||||||
|
pub async fn remove_role_from_user(
|
||||||
|
&self,
|
||||||
|
guild_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
role_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM member_roles WHERE guild_id=? AND user_id=? AND role_id=?")
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all roles defined in a guild.
|
||||||
|
pub async fn list_guild_roles(&self, guild_id: &str) -> Result<Vec<RoleFullRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, RoleFullRow>(
|
||||||
|
"SELECT id, guild_id, name, color, permissions, position, created_at \
|
||||||
|
FROM roles WHERE guild_id=? ORDER BY position DESC",
|
||||||
|
)
|
||||||
|
.bind(guild_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
|
#[derive(sqlx::FromRow, Debug, Clone)]
|
||||||
|
pub struct RoleFullRow {
|
||||||
|
pub id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
pub permissions: i64,
|
||||||
|
pub position: i32,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
117
gateway/src/domain/storage/guilds/roles.rs
Normal file
117
gateway/src/domain/storage/guilds/roles.rs
Normal file
|
|
@ -0,0 +1,117 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use super::InviteRow;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow, Debug)]
|
||||||
|
pub struct RoleRow {
|
||||||
|
pub color: String,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::super::Storage {
|
||||||
|
pub async fn create_role(
|
||||||
|
&self,
|
||||||
|
guild_id: &str,
|
||||||
|
name: &str,
|
||||||
|
color: &str,
|
||||||
|
permissions: u64,
|
||||||
|
position: i32,
|
||||||
|
) -> Result<String> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
sqlx::query("INSERT INTO roles (id,guild_id,name,color,permissions,position,created_at) VALUES (?,?,?,?,?,?,?)")
|
||||||
|
.bind(&id).bind(guild_id).bind(name).bind(color).bind(permissions as i64).bind(position).bind(super::super::now_ms())
|
||||||
|
.execute(&self.pool).await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_role(&self, role_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM roles WHERE id=?")
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
sqlx::query("DELETE FROM member_roles WHERE role_id=?")
|
||||||
|
.bind(role_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_roles(&self, guild_id: &str, user_id: &str) -> Result<Vec<RoleRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, RoleRow>(
|
||||||
|
"SELECT r.id,r.guild_id,r.name,r.color,r.permissions,r.position \
|
||||||
|
FROM roles r JOIN member_roles mr ON r.id=mr.role_id \
|
||||||
|
WHERE mr.guild_id=? AND mr.user_id=? ORDER BY r.position DESC",
|
||||||
|
)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_user_role_perms(&self, guild_id: &str, user_id: &str) -> Result<Vec<u64>> {
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct P {
|
||||||
|
permissions: i64,
|
||||||
|
}
|
||||||
|
let rows: Vec<P> = sqlx::query_as::<_, P>(
|
||||||
|
"SELECT r.permissions FROM roles r \
|
||||||
|
JOIN member_roles mr ON r.id=mr.role_id \
|
||||||
|
WHERE mr.guild_id=? AND mr.user_id=?",
|
||||||
|
)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows.into_iter().map(|r| r.permissions as u64).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_invite(
|
||||||
|
&self,
|
||||||
|
guild_id: &str,
|
||||||
|
creator_id: &str,
|
||||||
|
max_uses: Option<i64>,
|
||||||
|
expires_in_s: Option<i64>,
|
||||||
|
) -> Result<InviteRow> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let code = super::super::generate_invite_code();
|
||||||
|
let now = super::super::now_ms();
|
||||||
|
let expires_at = expires_in_s.map(|s| now + s * 1000);
|
||||||
|
sqlx::query("INSERT INTO invites (id,guild_id,creator_id,code,max_uses,expires_at,created_at) VALUES (?,?,?,?,?,?,?)")
|
||||||
|
.bind(&id).bind(guild_id).bind(creator_id).bind(&code).bind(max_uses).bind(expires_at).bind(now)
|
||||||
|
.execute(&self.pool).await?;
|
||||||
|
Ok(InviteRow {
|
||||||
|
id,
|
||||||
|
guild_id: guild_id.into(),
|
||||||
|
guild_name: String::new(),
|
||||||
|
creator_id: creator_id.into(),
|
||||||
|
code,
|
||||||
|
max_uses,
|
||||||
|
uses: 0,
|
||||||
|
expires_at,
|
||||||
|
created_at: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_invite_by_code(&self, code: &str) -> Result<Option<InviteRow>> {
|
||||||
|
Ok(sqlx::query_as::<_, InviteRow>(
|
||||||
|
"SELECT i.id,i.guild_id,i.creator_id,i.code,i.max_uses,i.uses,i.expires_at,i.created_at, \
|
||||||
|
g.name as guild_name FROM invites i JOIN guilds g ON i.guild_id=g.id WHERE i.code=?"
|
||||||
|
).bind(code).fetch_optional(&self.pool).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn use_invite(&self, invite_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE invites SET uses=uses+1 WHERE id=?")
|
||||||
|
.bind(invite_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_invite(&self, invite_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM invites WHERE id=?")
|
||||||
|
.bind(invite_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
149
gateway/src/domain/storage/messages.rs
Normal file
149
gateway/src/domain/storage/messages.rs
Normal file
|
|
@ -0,0 +1,149 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::proto::ChatMessagePayload;
|
||||||
|
|
||||||
|
#[derive(sqlx::FromRow)]
|
||||||
|
struct MsgRow {
|
||||||
|
id: String,
|
||||||
|
channel_id: String,
|
||||||
|
sender_id: String,
|
||||||
|
content: String,
|
||||||
|
timestamp: i64,
|
||||||
|
#[sqlx(default)]
|
||||||
|
reply_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl super::Storage {
|
||||||
|
pub async fn save_message(&self, msg: &ChatMessagePayload) -> Result<()> {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO messages (id,channel_id,sender_id,content,timestamp,reply_to) VALUES (?,?,?,?,?,?)")
|
||||||
|
.bind(&msg.message_id).bind(&msg.channel_id).bind(&msg.sender_id)
|
||||||
|
.bind(&msg.content).bind(msg.timestamp).bind(&msg.reply_to)
|
||||||
|
.execute(&self.pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_history(
|
||||||
|
&self,
|
||||||
|
channel_id: &str,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<ChatMessagePayload>> {
|
||||||
|
let rows = sqlx::query_as::<_, MsgRow>(
|
||||||
|
"SELECT id,channel_id,sender_id,content,timestamp,reply_to FROM \
|
||||||
|
(SELECT * FROM messages WHERE channel_id=? ORDER BY timestamp DESC LIMIT ?) \
|
||||||
|
ORDER BY timestamp ASC",
|
||||||
|
)
|
||||||
|
.bind(channel_id)
|
||||||
|
.bind(limit)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| ChatMessagePayload {
|
||||||
|
message_id: r.id,
|
||||||
|
channel_id: r.channel_id,
|
||||||
|
sender_id: r.sender_id,
|
||||||
|
content: r.content,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
edited: false,
|
||||||
|
reply_to: r.reply_to,
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn update_read_receipt(
|
||||||
|
&self,
|
||||||
|
channel_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
message_id: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR REPLACE INTO read_receipts (channel_id,user_id,last_read_message_id,updated_at) VALUES (?,?,?,?)",
|
||||||
|
).bind(channel_id).bind(user_id).bind(message_id).bind(super::now_ms())
|
||||||
|
.execute(&self.pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn add_reaction(&self, message_id: &str, user_id: &str, emoji: &str) -> Result<()> {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO reactions (message_id,user_id,emoji,created_at) VALUES (?,?,?,?)")
|
||||||
|
.bind(message_id).bind(user_id).bind(emoji).bind(super::now_ms())
|
||||||
|
.execute(&self.pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_reaction(
|
||||||
|
&self,
|
||||||
|
message_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
emoji: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM reactions WHERE message_id=? AND user_id=? AND emoji=?")
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(emoji)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn has_user_reacted(
|
||||||
|
&self,
|
||||||
|
message_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
emoji: &str,
|
||||||
|
) -> Result<bool> {
|
||||||
|
Ok(sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT user_id FROM reactions WHERE message_id=? AND user_id=? AND emoji=?",
|
||||||
|
)
|
||||||
|
.bind(message_id)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(emoji)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn edit_message(&self, message_id: &str, new_content: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE messages SET content=? WHERE id=?")
|
||||||
|
.bind(new_content)
|
||||||
|
.bind(message_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_message(&self, message_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM messages WHERE id=?")
|
||||||
|
.bind(message_id)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_message_sender(&self, message_id: &str) -> Result<Option<String>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, (String,)>("SELECT sender_id FROM messages WHERE id=?")
|
||||||
|
.bind(message_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(|(id,)| id),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_message(&self, message_id: &str) -> Result<Option<ChatMessagePayload>> {
|
||||||
|
Ok(sqlx::query_as::<_, MsgRow>(
|
||||||
|
"SELECT id,channel_id,sender_id,content,timestamp,reply_to FROM messages WHERE id=?",
|
||||||
|
)
|
||||||
|
.bind(message_id)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.map(|r| ChatMessagePayload {
|
||||||
|
message_id: r.id,
|
||||||
|
channel_id: r.channel_id,
|
||||||
|
sender_id: r.sender_id,
|
||||||
|
content: r.content,
|
||||||
|
timestamp: r.timestamp,
|
||||||
|
edited: false,
|
||||||
|
reply_to: r.reply_to,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
223
gateway/src/domain/storage/mod.rs
Normal file
223
gateway/src/domain/storage/mod.rs
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
pub mod dm_unread;
|
||||||
|
pub mod dms;
|
||||||
|
pub mod guilds;
|
||||||
|
pub mod messages;
|
||||||
|
pub mod social;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
pub struct Storage {
|
||||||
|
pub pool: SqlitePool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Storage {
|
||||||
|
pub async fn connect(path: &str) -> Result<Self> {
|
||||||
|
if let Some(p) = std::path::Path::new(path).parent() {
|
||||||
|
tokio::fs::create_dir_all(p).await?;
|
||||||
|
}
|
||||||
|
let pool = SqlitePool::connect(&format!("sqlite://{}?mode=rwc", path)).await?;
|
||||||
|
let s = Self { pool };
|
||||||
|
s.migrate().await?;
|
||||||
|
info!("storage: {path}");
|
||||||
|
Ok(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn migrate(&self) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"CREATE TABLE IF NOT EXISTS messages (
|
||||||
|
id TEXT PRIMARY KEY, channel_id TEXT NOT NULL,
|
||||||
|
sender_id TEXT NOT NULL, content TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_msg_ch ON messages (channel_id, timestamp);
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
pubkey TEXT PRIMARY KEY, nickname TEXT NOT NULL, first_seen INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS bans (
|
||||||
|
pubkey TEXT PRIMARY KEY, reason TEXT, banned_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS direct_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user1_id TEXT NOT NULL,
|
||||||
|
user2_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL,
|
||||||
|
last_message_at INTEGER,
|
||||||
|
unread_count_1 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unread_count_2 INTEGER NOT NULL DEFAULT 0,
|
||||||
|
UNIQUE(user1_id, user2_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS dm_messages (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
dm_id TEXT NOT NULL REFERENCES direct_messages(id),
|
||||||
|
sender_id TEXT NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_dm_msg_dm ON dm_messages(dm_id, created_at);
|
||||||
|
-- Guild system (Phase 1.2)
|
||||||
|
CREATE TABLE IF NOT EXISTS guilds (
|
||||||
|
id TEXT PRIMARY KEY, owner_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS guild_members (
|
||||||
|
guild_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
||||||
|
joined_at INTEGER NOT NULL, PRIMARY KEY(guild_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gm_user ON guild_members(user_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS roles (
|
||||||
|
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, name TEXT NOT NULL,
|
||||||
|
color TEXT NOT NULL DEFAULT '#ffffff',
|
||||||
|
permissions INTEGER NOT NULL DEFAULT 0,
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_roles_guild ON roles(guild_id);
|
||||||
|
CREATE TABLE IF NOT EXISTS invites (
|
||||||
|
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, creator_id TEXT NOT NULL,
|
||||||
|
code TEXT NOT NULL UNIQUE, max_uses INTEGER,
|
||||||
|
uses INTEGER NOT NULL DEFAULT 0,
|
||||||
|
expires_at INTEGER, created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code);
|
||||||
|
CREATE TABLE IF NOT EXISTS member_roles (
|
||||||
|
guild_id TEXT NOT NULL, user_id TEXT NOT NULL, role_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY(guild_id, user_id, role_id)
|
||||||
|
);
|
||||||
|
-- Friends system (Phase 1.2)
|
||||||
|
CREATE TABLE IF NOT EXISTS friend_requests (
|
||||||
|
id TEXT PRIMARY KEY, from_user_id TEXT NOT NULL, to_user_id TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'PENDING', created_at INTEGER NOT NULL,
|
||||||
|
UNIQUE(from_user_id, to_user_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS friendships (
|
||||||
|
user_id_1 TEXT NOT NULL, user_id_2 TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL, PRIMARY KEY(user_id_1, user_id_2)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS read_receipts (
|
||||||
|
channel_id TEXT NOT NULL, user_id TEXT NOT NULL,
|
||||||
|
last_read_message_id TEXT NOT NULL, updated_at INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY(channel_id, user_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS blocks (
|
||||||
|
blocker_id TEXT NOT NULL, blocked_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER NOT NULL, PRIMARY KEY(blocker_id, blocked_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS reactions (
|
||||||
|
message_id TEXT, user_id TEXT, emoji TEXT, created_at INTEGER,
|
||||||
|
PRIMARY KEY(message_id, user_id, emoji)
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS audit_logs (
|
||||||
|
id TEXT PRIMARY KEY, guild_id TEXT NOT NULL, actor_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, target_id TEXT,
|
||||||
|
target_type TEXT, reason TEXT, changes TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_audit_guild ON audit_logs(guild_id, created_at);",
|
||||||
|
)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Lightweight migrations for pre-existing databases (idempotent).
|
||||||
|
self.ensure_column("messages", "reply_to", "TEXT").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Add a column to a table if it doesn't already exist. Idempotent.
|
||||||
|
/// Only called with hardcoded literals — safe to bypass sqlx SqlSafeStr check.
|
||||||
|
async fn ensure_column(
|
||||||
|
&self,
|
||||||
|
table: &'static str,
|
||||||
|
col: &'static str,
|
||||||
|
decl: &'static str,
|
||||||
|
) -> Result<()> {
|
||||||
|
use sqlx::AssertSqlSafe;
|
||||||
|
// PRAGMA + ALTER can't use bind parameters in SQLite; use AssertSqlSafe
|
||||||
|
// with hardcoded literals only — never user input.
|
||||||
|
type PragmaRow = (i64, String, String, i64, Option<String>, i64);
|
||||||
|
let pragma = format!("PRAGMA table_info({table})");
|
||||||
|
let rows: Result<Vec<PragmaRow>, _> =
|
||||||
|
sqlx::query_as::<_, PragmaRow>(AssertSqlSafe(pragma.clone()))
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await;
|
||||||
|
let rows = match rows {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("ensure_column: PRAGMA failed: {e}");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if rows.iter().any(|(_, name, _, _, _, _)| name == col) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let alter = format!("ALTER TABLE {table} ADD COLUMN {col} {decl}");
|
||||||
|
sqlx::query(AssertSqlSafe(alter))
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
info!("storage: added column {table}.{col}");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn upsert_user(&self, pubkey: &str, nickname: &str) -> Result<()> {
|
||||||
|
sqlx::query("INSERT OR IGNORE INTO users (pubkey,nickname,first_seen) VALUES (?,?,?)")
|
||||||
|
.bind(pubkey)
|
||||||
|
.bind(nickname)
|
||||||
|
.bind(now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_banned(&self, pubkey: &str) -> Result<bool> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, (String,)>("SELECT pubkey FROM bans WHERE pubkey=?")
|
||||||
|
.bind(pubkey)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.is_some(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn append_audit_log(
|
||||||
|
&self,
|
||||||
|
guild_id: &str,
|
||||||
|
actor_id: &str,
|
||||||
|
action: &str,
|
||||||
|
target_id: Option<&str>,
|
||||||
|
target_type: Option<&str>,
|
||||||
|
reason: Option<&str>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
let now = now_ms();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO audit_logs (id,guild_id,actor_id,action,target_id,target_type,reason,created_at) \
|
||||||
|
VALUES (?,?,?,?,?,?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(&id)
|
||||||
|
.bind(guild_id)
|
||||||
|
.bind(actor_id)
|
||||||
|
.bind(action)
|
||||||
|
.bind(target_id)
|
||||||
|
.bind(target_type)
|
||||||
|
.bind(reason)
|
||||||
|
.bind(now)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn generate_invite_code() -> String {
|
||||||
|
use std::collections::hash_map::RandomState;
|
||||||
|
use std::hash::{BuildHasher, Hasher};
|
||||||
|
let hash = RandomState::new().build_hasher().finish();
|
||||||
|
format!("{:08x}", hash % 0x100000000u64)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
144
gateway/src/domain/storage/social.rs
Normal file
144
gateway/src/domain/storage/social.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
impl super::Storage {
|
||||||
|
pub async fn create_friend_request(&self, from_id: &str, to_id: &str) -> Result<bool> {
|
||||||
|
let (u1, u2) = if from_id < to_id {
|
||||||
|
(from_id, to_id)
|
||||||
|
} else {
|
||||||
|
(to_id, from_id)
|
||||||
|
};
|
||||||
|
let exists = sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT user_id_1 FROM friendships WHERE user_id_1=? AND user_id_2=?",
|
||||||
|
)
|
||||||
|
.bind(u1)
|
||||||
|
.bind(u2)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?;
|
||||||
|
if exists.is_some() {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let id = uuid::Uuid::new_v4().to_string();
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO friend_requests (id,from_user_id,to_user_id,status,created_at) VALUES (?,?,?,?,?)"
|
||||||
|
).bind(&id).bind(from_id).bind(to_id).bind("PENDING").bind(super::now_ms())
|
||||||
|
.execute(&self.pool).await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn accept_friend_request(&self, from_id: &str, to_id: &str) -> Result<bool> {
|
||||||
|
let updated = sqlx::query(
|
||||||
|
"UPDATE friend_requests SET status='ACCEPTED' WHERE from_user_id=? AND to_user_id=? AND status='PENDING'"
|
||||||
|
).bind(from_id).bind(to_id).execute(&self.pool).await?;
|
||||||
|
if updated.rows_affected() == 0 {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
let (u1, u2) = if from_id < to_id {
|
||||||
|
(from_id, to_id)
|
||||||
|
} else {
|
||||||
|
(to_id, from_id)
|
||||||
|
};
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO friendships (user_id_1,user_id_2,created_at) VALUES (?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(u1)
|
||||||
|
.bind(u2)
|
||||||
|
.bind(super::now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn decline_friend_request(&self, from_id: &str, to_id: &str) -> Result<()> {
|
||||||
|
sqlx::query("UPDATE friend_requests SET status='DECLINED' WHERE from_user_id=? AND to_user_id=? AND status='PENDING'")
|
||||||
|
.bind(from_id).bind(to_id).execute(&self.pool).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn remove_friend(&self, user_a: &str, user_b: &str) -> Result<()> {
|
||||||
|
let (u1, u2) = if user_a < user_b {
|
||||||
|
(user_a, user_b)
|
||||||
|
} else {
|
||||||
|
(user_b, user_a)
|
||||||
|
};
|
||||||
|
sqlx::query("DELETE FROM friendships WHERE user_id_1=? AND user_id_2=?")
|
||||||
|
.bind(u1)
|
||||||
|
.bind(u2)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_friends(&self, user_id: &str) -> Result<Vec<String>> {
|
||||||
|
let rows1 =
|
||||||
|
sqlx::query_as::<_, (String,)>("SELECT user_id_2 FROM friendships WHERE user_id_1=?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
let rows2 =
|
||||||
|
sqlx::query_as::<_, (String,)>("SELECT user_id_1 FROM friendships WHERE user_id_2=?")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows1.into_iter().chain(rows2).map(|(id,)| id).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_friend(&self, user_a: &str, user_b: &str) -> Result<bool> {
|
||||||
|
let (u1, u2) = if user_a < user_b {
|
||||||
|
(user_a, user_b)
|
||||||
|
} else {
|
||||||
|
(user_b, user_a)
|
||||||
|
};
|
||||||
|
Ok(sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT user_id_1 FROM friendships WHERE user_id_1=? AND user_id_2=?",
|
||||||
|
)
|
||||||
|
.bind(u1)
|
||||||
|
.bind(u2)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn block_user(&self, blocker: &str, blocked: &str) -> Result<()> {
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT OR IGNORE INTO blocks (blocker_id,blocked_id,created_at) VALUES (?,?,?)",
|
||||||
|
)
|
||||||
|
.bind(blocker)
|
||||||
|
.bind(blocked)
|
||||||
|
.bind(super::now_ms())
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn unblock_user(&self, blocker: &str, blocked: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM blocks WHERE blocker_id=? AND blocked_id=?")
|
||||||
|
.bind(blocker)
|
||||||
|
.bind(blocked)
|
||||||
|
.execute(&self.pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_blocked(&self, blocker: &str, blocked: &str) -> Result<bool> {
|
||||||
|
Ok(sqlx::query_as::<_, (String,)>(
|
||||||
|
"SELECT blocker_id FROM blocks WHERE blocker_id=? AND blocked_id=?",
|
||||||
|
)
|
||||||
|
.bind(blocker)
|
||||||
|
.bind(blocked)
|
||||||
|
.fetch_optional(&self.pool)
|
||||||
|
.await?
|
||||||
|
.is_some())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list_blocks(&self, blocker: &str) -> Result<Vec<String>> {
|
||||||
|
Ok(
|
||||||
|
sqlx::query_as::<_, (String,)>("SELECT blocked_id FROM blocks WHERE blocker_id=?")
|
||||||
|
.bind(blocker)
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|(id,)| id)
|
||||||
|
.collect(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
251
gateway/src/handler/channel/create.rs
Normal file
251
gateway/src/handler/channel/create.rs
Normal file
|
|
@ -0,0 +1,251 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tracing::{info, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{channels, session},
|
||||||
|
net::{
|
||||||
|
io,
|
||||||
|
state::{BroadcastMsg, State},
|
||||||
|
},
|
||||||
|
proto::{
|
||||||
|
ChannelCreatePayload, ChannelDeletePayload, ChannelListItem, ChannelListPayload,
|
||||||
|
ChannelStatePayload, PacketId, SessionCrypto, encode_packet, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Handle a ChannelCreate request — register a new channel in the store and
|
||||||
|
/// broadcast the new ChannelState to all sessions so their sidebars update.
|
||||||
|
pub async fn handle_channel_create(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: ChannelCreatePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate kind.
|
||||||
|
let kind = match req.kind.as_str() {
|
||||||
|
"text" => channels::ChannelKind::Text,
|
||||||
|
"voice" => channels::ChannelKind::Voice,
|
||||||
|
other => {
|
||||||
|
warn!(
|
||||||
|
"channel_create from {}: invalid kind '{other}'",
|
||||||
|
sess.nickname
|
||||||
|
);
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: crate::proto::ErrorCode::InvalidPacket as u32,
|
||||||
|
message: format!("invalid channel kind: {other}"),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let channel_id = req.channel_id.trim().to_string();
|
||||||
|
let channel_name = if req.channel_name.trim().is_empty() {
|
||||||
|
channel_id.clone()
|
||||||
|
} else {
|
||||||
|
req.channel_name.trim().to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
if channel_id.is_empty() {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: crate::proto::ErrorCode::InvalidPacket as u32,
|
||||||
|
message: "channel_id is required".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limit channel-create rate (reuse the per-session token bucket — 1 token
|
||||||
|
// means 1 channel-create per rate window).
|
||||||
|
if !state.rate_limiter.try_consume(session_id) {
|
||||||
|
state.metrics.inc(&state.metrics.rate_limited_events);
|
||||||
|
warn!("rate-limited channel_create from {}", sess.nickname);
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: crate::proto::ErrorCode::RateLimited as u32,
|
||||||
|
message: "slow down — too many channel operations".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let created = channels::create(&state.channels, &channel_id, &channel_name, kind.clone()).await;
|
||||||
|
|
||||||
|
if !created {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: crate::proto::ErrorCode::ChannelNotFound as u32,
|
||||||
|
message: "channel already exists".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"channel_create: {} created '{}' ({}) by {}",
|
||||||
|
channel_id,
|
||||||
|
channel_name,
|
||||||
|
kind.as_str(),
|
||||||
|
sess.nickname
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reply to creator with ChannelState (no members yet).
|
||||||
|
let sp = ChannelStatePayload {
|
||||||
|
channel_id: channel_id.clone(),
|
||||||
|
channel_name: channel_name.clone(),
|
||||||
|
kind: kind.as_str().into(),
|
||||||
|
members: Vec::new(),
|
||||||
|
voice_endpoint: state.config.voice.bind.clone(),
|
||||||
|
};
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::ChannelState,
|
||||||
|
seq,
|
||||||
|
&to_payload(&sp),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Broadcast a ChannelCreate event to all other sessions so their sidebars update.
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: None,
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(
|
||||||
|
PacketId::ChannelCreate,
|
||||||
|
0,
|
||||||
|
&to_payload(&ChannelCreatePayload {
|
||||||
|
channel_id: channel_id.clone(),
|
||||||
|
channel_name: channel_name.clone(),
|
||||||
|
kind: kind.as_str().into(),
|
||||||
|
guild_id: req.guild_id.clone(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a ChannelDelete request — remove the channel from the store and
|
||||||
|
/// broadcast the deletion to all sessions.
|
||||||
|
pub async fn handle_channel_delete(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: ChannelDeletePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Protect default channels from deletion.
|
||||||
|
if req.channel_id == "general" || req.channel_id == "voice" {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: crate::proto::ErrorCode::PermissionDenied as u32,
|
||||||
|
message: "cannot delete default channels".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let existed = channels::delete(&state.channels, &req.channel_id).await;
|
||||||
|
if !existed {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: crate::proto::ErrorCode::ChannelNotFound as u32,
|
||||||
|
message: "channel not found".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"channel_delete: '{}' removed by {}",
|
||||||
|
req.channel_id, sess.nickname
|
||||||
|
);
|
||||||
|
|
||||||
|
// Broadcast deletion to all sessions.
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: None,
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(
|
||||||
|
PacketId::ChannelDelete,
|
||||||
|
0,
|
||||||
|
&to_payload(&ChannelDeletePayload {
|
||||||
|
channel_id: req.channel_id.clone(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a ChannelList request — reply with all known channels.
|
||||||
|
pub async fn handle_channel_list(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let _sess = session::get(&state.sessions, session_id).await;
|
||||||
|
let channels = channels::list(&state.channels).await;
|
||||||
|
let items: Vec<ChannelListItem> = channels
|
||||||
|
.iter()
|
||||||
|
.map(|c| ChannelListItem {
|
||||||
|
channel_id: c.id.clone(),
|
||||||
|
channel_name: c.name.clone(),
|
||||||
|
kind: c.kind.as_str().into(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let p = ChannelListPayload { channels: items };
|
||||||
|
io::send_encrypted(stream, PacketId::ChannelList, seq, &to_payload(&p), crypto).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
115
gateway/src/handler/channel/join.rs
Normal file
115
gateway/src/handler/channel/join.rs
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{channels, session},
|
||||||
|
net::{
|
||||||
|
io,
|
||||||
|
state::{BroadcastMsg, State},
|
||||||
|
},
|
||||||
|
proto::{
|
||||||
|
self, ChannelStatePayload, ChatHistoryPayload, MemberInfo, PacketId, SessionCrypto,
|
||||||
|
UserJoinPayload, encode_packet, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{broadcast_leave, set_channel};
|
||||||
|
|
||||||
|
const HISTORY_LIMIT: i64 = 50;
|
||||||
|
|
||||||
|
pub async fn join(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
channel_id: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let prev_channel = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.and_then(|s| s.channel_id);
|
||||||
|
|
||||||
|
if prev_channel.as_deref() == Some(channel_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(prev) = prev_channel {
|
||||||
|
channels::leave(&state.channels, &prev, session_id).await;
|
||||||
|
broadcast_leave(state, &prev, session_id).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let ch = match channels::get_channel(&state.channels, channel_id).await {
|
||||||
|
Some(c) => c,
|
||||||
|
None => {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&proto::ErrorPayload {
|
||||||
|
code: proto::ErrorCode::ChannelNotFound as u32,
|
||||||
|
message: "not found".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
channels::join(&state.channels, channel_id, session_id).await;
|
||||||
|
set_channel(state, session_id, Some(channel_id.into())).await;
|
||||||
|
|
||||||
|
let mut members = Vec::new();
|
||||||
|
for sid in channels::members(&state.channels, channel_id).await {
|
||||||
|
if let Some(s) = session::get(&state.sessions, &sid).await {
|
||||||
|
members.push(MemberInfo {
|
||||||
|
user_id: s.user_id.clone(),
|
||||||
|
nickname: s.nickname.clone(),
|
||||||
|
in_voice: ch.kind == channels::ChannelKind::Voice,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let sp = ChannelStatePayload {
|
||||||
|
channel_id: ch.id.clone(),
|
||||||
|
channel_name: ch.name.clone(),
|
||||||
|
kind: ch.kind.as_str().into(),
|
||||||
|
members,
|
||||||
|
voice_endpoint: state.config.voice.bind.clone(),
|
||||||
|
};
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::ChannelState,
|
||||||
|
seq,
|
||||||
|
&to_payload(&sp),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let history = state.storage.get_history(channel_id, HISTORY_LIMIT).await?;
|
||||||
|
if !history.is_empty() {
|
||||||
|
let hp = ChatHistoryPayload {
|
||||||
|
channel_id: channel_id.into(),
|
||||||
|
messages: history,
|
||||||
|
};
|
||||||
|
io::send_encrypted(stream, PacketId::ChatHistory, seq, &to_payload(&hp), crypto).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(sess) = session::get(&state.sessions, session_id).await {
|
||||||
|
let jp = UserJoinPayload {
|
||||||
|
channel_id: channel_id.into(),
|
||||||
|
user_id: sess.user_id.clone(),
|
||||||
|
nickname: sess.nickname.clone(),
|
||||||
|
};
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(channel_id.into()),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::UserJoin, 0, &to_payload(&jp)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
info!("session {} joined {channel_id}", &session_id[..8]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
22
gateway/src/handler/channel/leave.rs
Normal file
22
gateway/src/handler/channel/leave.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use crate::{domain::channels, net::state::State, proto::SessionCrypto};
|
||||||
|
|
||||||
|
use super::{broadcast_leave, set_channel};
|
||||||
|
|
||||||
|
pub async fn leave(
|
||||||
|
_stream: &mut TcpStream,
|
||||||
|
_seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
channel_id: &str,
|
||||||
|
_crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
channels::leave(&state.channels, channel_id, session_id).await;
|
||||||
|
set_channel(state, session_id, None).await;
|
||||||
|
broadcast_leave(state, channel_id, session_id).await;
|
||||||
|
info!("session {} left {channel_id}", &session_id[..8]);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
33
gateway/src/handler/channel/mod.rs
Normal file
33
gateway/src/handler/channel/mod.rs
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
pub mod create;
|
||||||
|
pub mod join;
|
||||||
|
pub mod leave;
|
||||||
|
|
||||||
|
pub use create::{handle_channel_create, handle_channel_delete, handle_channel_list};
|
||||||
|
pub use join::join;
|
||||||
|
pub use leave::leave;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
net::state::{BroadcastMsg, State},
|
||||||
|
proto::{PacketId, UserLeavePayload, encode_packet, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn broadcast_leave(state: &State, channel_id: &str, session_id: &str) {
|
||||||
|
if let Some(sess) = crate::domain::session::get(&state.sessions, session_id).await {
|
||||||
|
let p = UserLeavePayload {
|
||||||
|
channel_id: channel_id.into(),
|
||||||
|
user_id: sess.user_id.clone(),
|
||||||
|
};
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(channel_id.into()),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::UserLeave, 0, &to_payload(&p)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_channel(state: &State, session_id: &str, ch: Option<String>) {
|
||||||
|
if let Some(s) = state.sessions.write().await.get_mut(session_id) {
|
||||||
|
s.channel_id = ch;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
gateway/src/handler/content/chat.rs
Normal file
66
gateway/src/handler/content/chat.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::{debug, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
net::state::{BroadcastMsg, State},
|
||||||
|
proto::{ChatMessagePayload, ErrorCode, PacketId, encode_packet, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle(session_id: &str, mut msg: ChatMessagePayload, state: &State) -> Result<()> {
|
||||||
|
let sess = match crate::domain::session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if sess.channel_id.as_deref() != Some(&msg.channel_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check (token bucket per session).
|
||||||
|
if !state.rate_limiter.try_consume(session_id) {
|
||||||
|
state.metrics.inc(&state.metrics.rate_limited_events);
|
||||||
|
warn!("rate-limited session {session_id}");
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: None,
|
||||||
|
exclude_session: None,
|
||||||
|
target_session_id: Some(session_id.to_string()),
|
||||||
|
data: encode_packet(
|
||||||
|
PacketId::Error,
|
||||||
|
0,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: ErrorCode::RateLimited as u32,
|
||||||
|
message: "you are sending messages too quickly".into(),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
msg.sender_id = sess.user_id.clone();
|
||||||
|
if msg.timestamp == 0 {
|
||||||
|
msg.timestamp = now_ms();
|
||||||
|
}
|
||||||
|
|
||||||
|
state.storage.save_message(&msg).await?;
|
||||||
|
state.metrics.inc(&state.metrics.messages_sent);
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(msg.channel_id.clone()),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::ChatMessage, 0, &to_payload(&msg)),
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"chat {} → {}: {}",
|
||||||
|
sess.nickname, msg.channel_id, msg.content
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
93
gateway/src/handler/content/message_edit.rs
Normal file
93
gateway/src/handler/content/message_edit.rs
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::state::{BroadcastMsg, State},
|
||||||
|
proto::{ChatMessagePayload, MessageDeletePayload, PacketId, encode_packet, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_message_edit(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
|
||||||
|
let edit: crate::proto::MessageEditPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if sess.channel_id.as_deref() != Some(&edit.channel_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sender owns the message
|
||||||
|
let sender = state.storage.get_message_sender(&edit.message_id).await?;
|
||||||
|
if sender.as_deref() != Some(&sess.user_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg = state.storage.get_message(&edit.message_id).await?;
|
||||||
|
let msg = match msg {
|
||||||
|
Some(m) => m,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.edit_message(&edit.message_id, &edit.content)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let broadcast_msg = ChatMessagePayload {
|
||||||
|
message_id: edit.message_id.clone(),
|
||||||
|
channel_id: edit.channel_id.clone(),
|
||||||
|
sender_id: sess.user_id.clone(),
|
||||||
|
content: edit.content,
|
||||||
|
timestamp: msg.timestamp,
|
||||||
|
edited: true,
|
||||||
|
reply_to: msg.reply_to.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(edit.channel_id.clone()),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::MessageEdit, 0, &to_payload(&broadcast_msg)),
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"edit {} {}:{}",
|
||||||
|
sess.nickname, edit.channel_id, edit.message_id
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_message_delete(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
|
||||||
|
let delete: MessageDeletePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if sess.channel_id.as_deref() != Some(&delete.channel_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sender owns the message
|
||||||
|
let sender = state.storage.get_message_sender(&delete.message_id).await?;
|
||||||
|
if sender.as_deref() != Some(&sess.user_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.storage.delete_message(&delete.message_id).await?;
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(delete.channel_id.clone()),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::MessageDelete, 0, &to_payload(&delete)),
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"delete {} {}:{}",
|
||||||
|
sess.nickname, delete.channel_id, delete.message_id
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
9
gateway/src/handler/content/mod.rs
Normal file
9
gateway/src/handler/content/mod.rs
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
pub mod chat;
|
||||||
|
pub mod message_edit;
|
||||||
|
pub mod presence;
|
||||||
|
pub mod reaction;
|
||||||
|
pub mod read_receipt;
|
||||||
|
pub mod typing;
|
||||||
|
|
||||||
|
pub use read_receipt::handle_read_receipt;
|
||||||
|
pub use typing::handle_typing_start;
|
||||||
84
gateway/src/handler/content/presence.rs
Normal file
84
gateway/src/handler/content/presence.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{
|
||||||
|
io,
|
||||||
|
state::{BroadcastMsg, State},
|
||||||
|
},
|
||||||
|
proto::{
|
||||||
|
PacketId, PresenceEventPayload, PresenceInfo, PresenceSyncPayload, PresenceUpdatePayload,
|
||||||
|
SessionCrypto, encode_packet, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_presence_update(
|
||||||
|
_stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
_crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: PresenceUpdatePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let info = PresenceInfo {
|
||||||
|
user_id: sess.user_id.clone(),
|
||||||
|
nickname: sess.nickname.clone(),
|
||||||
|
status: req.status.clone(),
|
||||||
|
activity_type: req.activity_type,
|
||||||
|
activity_text: req.activity_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.presences
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.insert(sess.user_id.clone(), info.clone());
|
||||||
|
|
||||||
|
// Broadcast to everyone (friends/guild-mates will filter client-side for now)
|
||||||
|
let event = PresenceEventPayload {
|
||||||
|
user_id: info.user_id,
|
||||||
|
nickname: info.nickname,
|
||||||
|
status: info.status,
|
||||||
|
activity_type: info.activity_type,
|
||||||
|
activity_text: info.activity_text,
|
||||||
|
};
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: None,
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::PresenceEvent, *seq, &to_payload(&event)),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_presence_sync(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let _sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let presences: Vec<PresenceInfo> = state.presences.read().await.values().cloned().collect();
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::PresenceSync,
|
||||||
|
seq,
|
||||||
|
&to_payload(&PresenceSyncPayload { presences }),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
92
gateway/src/handler/content/reaction.rs
Normal file
92
gateway/src/handler/content/reaction.rs
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::state::{BroadcastMsg, State},
|
||||||
|
proto::{PacketId, ReactionPayload, encode_packet, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_reaction_add(
|
||||||
|
session_id: &str,
|
||||||
|
mut payload: ReactionPayload,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if sess.channel_id.as_deref() != Some(&payload.channel_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify message exists in the channel
|
||||||
|
let msg = state.storage.get_message(&payload.message_id).await?;
|
||||||
|
if msg.is_none() || msg.as_ref().unwrap().channel_id != payload.channel_id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.user_id = sess.user_id.clone();
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.add_reaction(&payload.message_id, &sess.user_id, &payload.emoji)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(payload.channel_id.clone()),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::MessageReactionAdd, 0, &to_payload(&payload)),
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"reaction {} +{} on {}",
|
||||||
|
sess.nickname, payload.emoji, payload.message_id
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_reaction_remove(
|
||||||
|
session_id: &str,
|
||||||
|
mut payload: ReactionPayload,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if sess.channel_id.as_deref() != Some(&payload.channel_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
payload.user_id = sess.user_id.clone();
|
||||||
|
|
||||||
|
// Check user owns the reaction
|
||||||
|
let has = state
|
||||||
|
.storage
|
||||||
|
.has_user_reacted(&payload.message_id, &sess.user_id, &payload.emoji)
|
||||||
|
.await?;
|
||||||
|
if !has {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.remove_reaction(&payload.message_id, &sess.user_id, &payload.emoji)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(payload.channel_id.clone()),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::MessageReactionRemove, 0, &to_payload(&payload)),
|
||||||
|
});
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"reaction {} -{} on {}",
|
||||||
|
sess.nickname, payload.emoji, payload.message_id
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
46
gateway/src/handler/content/read_receipt.rs
Normal file
46
gateway/src/handler/content/read_receipt.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::state::{BroadcastMsg, State},
|
||||||
|
proto::{PacketId, ReadReceiptPayload, encode_packet, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_read_receipt(
|
||||||
|
_stream: &mut tokio::net::TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
_crypto: &crate::proto::SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: ReadReceiptPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.update_read_receipt(&req.channel_id, &sess.user_id, &req.last_read_message_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let broadcast_data = serde_json::json!({
|
||||||
|
"channel_id": req.channel_id,
|
||||||
|
"user_id": sess.user_id,
|
||||||
|
"last_read_message_id": req.last_read_message_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(req.channel_id),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(
|
||||||
|
PacketId::ReadReceiptBroadcast,
|
||||||
|
*seq,
|
||||||
|
&to_payload(&broadcast_data),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
30
gateway/src/handler/content/typing.rs
Normal file
30
gateway/src/handler/content/typing.rs
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::state::{BroadcastMsg, State},
|
||||||
|
proto::{PacketId, TypingStartPayload, encode_packet, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_typing_start(session_id: &str, payload: &[u8], state: &State) -> Result<()> {
|
||||||
|
let req: TypingStartPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = serde_json::json!({
|
||||||
|
"user_id": sess.user_id,
|
||||||
|
"nickname": sess.nickname,
|
||||||
|
"channel_id": req.channel_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: Some(req.channel_id),
|
||||||
|
exclude_session: Some(session_id.into()),
|
||||||
|
target_session_id: None,
|
||||||
|
data: encode_packet(PacketId::TypingStart, 0, &to_payload(&data)),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
39
gateway/src/handler/deliver.rs
Normal file
39
gateway/src/handler/deliver.rs
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{io, state::BroadcastMsg, state::State},
|
||||||
|
proto::SessionCrypto,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn deliver_encrypted(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
msg: &BroadcastMsg,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
if msg.exclude_session.as_deref() == Some(session_id) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(ref target) = msg.target_session_id {
|
||||||
|
if target != session_id {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
io::deliver_encrypted(stream, crypto, seq, &msg.data).await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
if let Some(ref ch) = msg.channel_id {
|
||||||
|
let in_ch = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.and_then(|s| s.channel_id)
|
||||||
|
.as_deref()
|
||||||
|
== Some(ch.as_str());
|
||||||
|
if !in_ch {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
io::deliver_encrypted(stream, crypto, seq, &msg.data).await
|
||||||
|
}
|
||||||
46
gateway/src/handler/direct_message/history.rs
Normal file
46
gateway/src/handler/direct_message/history.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{DmHistoryPayload, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_dm_history(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: DmHistoryPayload = serde_json::from_slice(payload)?;
|
||||||
|
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
let my_id = sess.user_id.clone();
|
||||||
|
drop(sess);
|
||||||
|
|
||||||
|
let _other_id = state
|
||||||
|
.storage
|
||||||
|
.get_dm_user_id(&req.dm_id, &my_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
|
||||||
|
|
||||||
|
let limit = req.limit.unwrap_or(50);
|
||||||
|
let messages = state
|
||||||
|
.storage
|
||||||
|
.get_dm_messages(&req.dm_id, limit, req.search_query.as_deref(), None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let resp = DmHistoryPayload {
|
||||||
|
dm_id: req.dm_id,
|
||||||
|
messages,
|
||||||
|
search_query: None,
|
||||||
|
limit: None,
|
||||||
|
};
|
||||||
|
io::send_encrypted(stream, PacketId::DmHistory, seq, &to_payload(&resp), crypto).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
7
gateway/src/handler/direct_message/mod.rs
Normal file
7
gateway/src/handler/direct_message/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
mod history;
|
||||||
|
mod send;
|
||||||
|
mod start;
|
||||||
|
|
||||||
|
pub use history::handle_dm_history;
|
||||||
|
pub use send::handle_dm_message;
|
||||||
|
pub use start::{handle_dm_read_ack, handle_dm_start};
|
||||||
113
gateway/src/handler/direct_message/send.rs
Normal file
113
gateway/src/handler/direct_message/send.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{
|
||||||
|
io,
|
||||||
|
state::{BroadcastMsg, State},
|
||||||
|
},
|
||||||
|
proto::{
|
||||||
|
self, DmMessagePayload, ErrorCode, PacketId, SessionCrypto, encode_packet, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_dm_message(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let msg: DmMessagePayload = serde_json::from_slice(payload)?;
|
||||||
|
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
let my_id = sess.user_id.clone();
|
||||||
|
drop(sess);
|
||||||
|
|
||||||
|
// Rate limit check (token bucket per session).
|
||||||
|
if !state.rate_limiter.try_consume(session_id) {
|
||||||
|
state.metrics.inc(&state.metrics.rate_limited_events);
|
||||||
|
warn!("rate-limited DM session {session_id}");
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&proto::ErrorPayload {
|
||||||
|
code: ErrorCode::RateLimited as u32,
|
||||||
|
message: "you are sending messages too quickly".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let other_id = state
|
||||||
|
.storage
|
||||||
|
.get_dm_user_id(&msg.dm_id, &my_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
|
||||||
|
if state.storage.is_blocked(&other_id, &my_id).await? {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&proto::ErrorPayload {
|
||||||
|
code: proto::ErrorCode::Blocked as u32,
|
||||||
|
message: "blocked".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let saved = state
|
||||||
|
.storage
|
||||||
|
.save_dm_message(&msg.dm_id, &my_id, &msg.content)
|
||||||
|
.await?;
|
||||||
|
state.metrics.inc(&state.metrics.dm_messages_sent);
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.increment_dm_unread(&msg.dm_id, &other_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
if let Some(recipient_sid) =
|
||||||
|
session::get_session_id_by_user_id(&state.sessions, &other_id).await
|
||||||
|
{
|
||||||
|
let data = encode_packet(
|
||||||
|
PacketId::DmMessage,
|
||||||
|
0,
|
||||||
|
&to_payload(&DmMessagePayload {
|
||||||
|
dm_id: msg.dm_id.clone(),
|
||||||
|
sender_id: my_id.clone(),
|
||||||
|
content: msg.content.clone(),
|
||||||
|
timestamp: saved.timestamp,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: None,
|
||||||
|
exclude_session: None,
|
||||||
|
target_session_id: Some(recipient_sid),
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
warn!("dm recipient offline: {}", &other_id[..8]);
|
||||||
|
}
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::DmMessage,
|
||||||
|
seq,
|
||||||
|
&to_payload(&saved),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
113
gateway/src/handler/direct_message/start.rs
Normal file
113
gateway/src/handler/direct_message/start.rs
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{self, DmStartPayload, DmStartResponsePayload, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_dm_start(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: DmStartPayload = serde_json::from_slice(payload)?;
|
||||||
|
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
let my_id = sess.user_id.clone();
|
||||||
|
drop(sess);
|
||||||
|
|
||||||
|
if state
|
||||||
|
.storage
|
||||||
|
.is_blocked(&req.target_user_id, &my_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&proto::ErrorPayload {
|
||||||
|
code: proto::ErrorCode::Blocked as u32,
|
||||||
|
message: "blocked".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.target_user_id == my_id {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&proto::ErrorPayload {
|
||||||
|
code: proto::ErrorCode::InvalidPacket as u32,
|
||||||
|
message: "cannot DM yourself".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (dm_id, unread_count) = state
|
||||||
|
.storage
|
||||||
|
.find_or_create_dm(&my_id, &req.target_user_id)
|
||||||
|
.await?;
|
||||||
|
let other_id = state
|
||||||
|
.storage
|
||||||
|
.get_dm_user_id(&dm_id, &my_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("user not in DM"))?;
|
||||||
|
let nickname = state
|
||||||
|
.storage
|
||||||
|
.get_dm_nickname(&other_id)
|
||||||
|
.await?
|
||||||
|
.unwrap_or_else(|| other_id[..8].to_string());
|
||||||
|
let messages = state
|
||||||
|
.storage
|
||||||
|
.get_dm_messages(&dm_id, 50, None, None)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let resp = DmStartResponsePayload {
|
||||||
|
dm_id,
|
||||||
|
other_user_id: other_id,
|
||||||
|
other_nickname: nickname,
|
||||||
|
messages,
|
||||||
|
unread_count: unread_count as u32,
|
||||||
|
};
|
||||||
|
io::send_encrypted(stream, PacketId::DmStart, seq, &to_payload(&resp), crypto).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_dm_read_ack(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: serde_json::Value = serde_json::from_slice(payload)?;
|
||||||
|
let dm_id = req["dm_id"]
|
||||||
|
.as_str()
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("missing dm_id"))?;
|
||||||
|
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
let my_id = sess.user_id.clone();
|
||||||
|
drop(sess);
|
||||||
|
|
||||||
|
state.storage.reset_dm_unread(dm_id, &my_id).await?;
|
||||||
|
|
||||||
|
io::send_encrypted(stream, PacketId::DmReadAck, seq, b"{}", crypto).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
187
gateway/src/handler/dispatch.rs
Normal file
187
gateway/src/handler/dispatch.rs
Normal file
|
|
@ -0,0 +1,187 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
net::io,
|
||||||
|
proto::{
|
||||||
|
ChatMessagePayload, JoinChannelPayload, LeaveChannelPayload, PacketId, PingPayload,
|
||||||
|
PongPayload, ReactionPayload, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Ctx, channel, content, direct_message, friends, guild};
|
||||||
|
|
||||||
|
pub async fn dispatch(
|
||||||
|
ctx: &mut Ctx<'_>,
|
||||||
|
session_id: &str,
|
||||||
|
pid: PacketId,
|
||||||
|
payload: &[u8],
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
) -> Result<()> {
|
||||||
|
let Ctx {
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crypto,
|
||||||
|
state,
|
||||||
|
} = ctx;
|
||||||
|
match pid {
|
||||||
|
PacketId::Ping => {
|
||||||
|
let ping: PingPayload = serde_json::from_slice(payload)?;
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Pong,
|
||||||
|
seq,
|
||||||
|
&to_payload(&PongPayload {
|
||||||
|
timestamp: ping.timestamp,
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::JoinChannel => {
|
||||||
|
let m: JoinChannelPayload = serde_json::from_slice(payload)?;
|
||||||
|
channel::join(stream, seq, session_id, &m.channel_id, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::LeaveChannel => {
|
||||||
|
let m: LeaveChannelPayload = serde_json::from_slice(payload)?;
|
||||||
|
channel::leave(stream, seq, session_id, &m.channel_id, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::ChannelCreate => {
|
||||||
|
channel::handle_channel_create(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::ChannelDelete => {
|
||||||
|
channel::handle_channel_delete(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::ChannelList => {
|
||||||
|
channel::handle_channel_list(stream, seq, session_id, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::ChatMessage => {
|
||||||
|
let m: ChatMessagePayload = serde_json::from_slice(payload)?;
|
||||||
|
content::chat::handle(session_id, m, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::DmStart => {
|
||||||
|
direct_message::handle_dm_start(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::DmMessage => {
|
||||||
|
direct_message::handle_dm_message(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::DmHistory => {
|
||||||
|
direct_message::handle_dm_history(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::DmReadAck => {
|
||||||
|
direct_message::handle_dm_read_ack(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildCreate => {
|
||||||
|
guild::handle_guild_create(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildDelete => {
|
||||||
|
guild::handle_guild_delete(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildList => {
|
||||||
|
guild::handle_guild_list(stream, seq, session_id, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildMemberJoin => {
|
||||||
|
guild::handle_guild_member_join(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildMemberLeave => {
|
||||||
|
guild::handle_guild_member_leave(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildMemberKick => {
|
||||||
|
guild::handle_guild_member_kick(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::RoleCreate => {
|
||||||
|
guild::handle_role_create(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::RoleDelete => {
|
||||||
|
guild::handle_role_delete(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::InviteCreate => {
|
||||||
|
guild::handle_invite_create(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::InviteAccept => {
|
||||||
|
guild::handle_invite_accept(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::InviteDelete => {
|
||||||
|
guild::handle_invite_delete(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildAuditLogFetch => {
|
||||||
|
guild::handle_audit_log_fetch(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildMemberListFetch => {
|
||||||
|
guild::handle_member_list_fetch(stream, seq, session_id, payload, crypto, state)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildRoleAssign => {
|
||||||
|
guild::handle_role_assign(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildRoleUnassign => {
|
||||||
|
guild::handle_role_unassign(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::GuildRoleListFetch => {
|
||||||
|
guild::handle_role_list_fetch(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::PresenceUpdate => {
|
||||||
|
content::presence::handle_presence_update(
|
||||||
|
stream, seq, session_id, payload, crypto, state,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PacketId::PresenceSync => {
|
||||||
|
content::presence::handle_presence_sync(stream, seq, session_id, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::FriendRequest => {
|
||||||
|
friends::handle_friend_request(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::FriendAccept => {
|
||||||
|
friends::handle_friend_accept(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::FriendDecline => {
|
||||||
|
friends::handle_friend_decline(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::FriendRemove => {
|
||||||
|
friends::handle_friend_remove(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::FriendList => {
|
||||||
|
friends::handle_friend_list(stream, seq, session_id, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::BlockUser => {
|
||||||
|
friends::handle_block_user(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::UnblockUser => {
|
||||||
|
friends::handle_unblock_user(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::BlockList => {
|
||||||
|
friends::handle_block_list(stream, seq, session_id, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::MessageReactionAdd => {
|
||||||
|
let m: ReactionPayload = serde_json::from_slice(payload)?;
|
||||||
|
content::reaction::handle_reaction_add(session_id, m, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::MessageReactionRemove => {
|
||||||
|
let m: ReactionPayload = serde_json::from_slice(payload)?;
|
||||||
|
content::reaction::handle_reaction_remove(session_id, m, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::MessageEdit => {
|
||||||
|
content::message_edit::handle_message_edit(session_id, payload, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::MessageDelete => {
|
||||||
|
content::message_edit::handle_message_delete(session_id, payload, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::TypingStart => {
|
||||||
|
content::handle_typing_start(session_id, payload, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::ReadReceipt => {
|
||||||
|
content::handle_read_receipt(stream, seq, session_id, payload, crypto, state).await?;
|
||||||
|
}
|
||||||
|
PacketId::Disconnect => debug!("{addr} DISCONNECT"),
|
||||||
|
other => debug!("{addr} unhandled {:?}", other),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
37
gateway/src/handler/friends/decline.rs
Normal file
37
gateway/src/handler/friends/decline.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::io,
|
||||||
|
proto::{FriendDeclinePayload, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_friend_decline(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &crate::net::state::State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: FriendDeclinePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.decline_friend_request(&req.from_user_id, &sess.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::FriendDecline,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"status": "DECLINED"})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
47
gateway/src/handler/friends/list.rs
Normal file
47
gateway/src/handler/friends/list.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{FriendInfo, FriendListPayload, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_friend_list(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let friend_ids = state.storage.list_friends(&sess.user_id).await?;
|
||||||
|
let mut friends = Vec::new();
|
||||||
|
let presences = state.presences.read().await;
|
||||||
|
|
||||||
|
for fid in &friend_ids {
|
||||||
|
let nick = state.storage.get_nickname(fid).await?.unwrap_or_default();
|
||||||
|
let presence = presences.get(fid);
|
||||||
|
friends.push(FriendInfo {
|
||||||
|
user_id: fid.clone(),
|
||||||
|
nickname: nick,
|
||||||
|
status: presence
|
||||||
|
.map(|p| p.status.clone())
|
||||||
|
.unwrap_or_else(|| "OFFLINE".into()),
|
||||||
|
since: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::FriendList,
|
||||||
|
seq,
|
||||||
|
&to_payload(&FriendListPayload { friends }),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
122
gateway/src/handler/friends/manage.rs
Normal file
122
gateway/src/handler/friends/manage.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{
|
||||||
|
BlockListPayload, BlockUserPayload, FriendRemovePayload, PacketId, SessionCrypto,
|
||||||
|
UnblockUserPayload, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_friend_remove(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: FriendRemovePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.remove_friend(&sess.user_id, &req.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::FriendRemove,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"removed": true})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_block_user(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: BlockUserPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.block_user(&sess.user_id, &req.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::BlockUser,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"blocked": true})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_unblock_user(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: UnblockUserPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.unblock_user(&sess.user_id, &req.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::UnblockUser,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"unblocked": true})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_block_list(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let blocked = state.storage.list_blocks(&sess.user_id).await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::BlockList,
|
||||||
|
seq,
|
||||||
|
&to_payload(&BlockListPayload { blocked }),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
37
gateway/src/handler/friends/mod.rs
Normal file
37
gateway/src/handler/friends/mod.rs
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
mod decline;
|
||||||
|
mod list;
|
||||||
|
mod manage;
|
||||||
|
mod requests;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
net::io,
|
||||||
|
proto::{ErrorPayload, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn send_err(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
code: crate::proto::ErrorCode,
|
||||||
|
msg: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
) -> Result<()> {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&ErrorPayload {
|
||||||
|
code: code as u32,
|
||||||
|
message: msg.into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use decline::handle_friend_decline;
|
||||||
|
pub use list::handle_friend_list;
|
||||||
|
pub use manage::{handle_block_list, handle_block_user, handle_friend_remove, handle_unblock_user};
|
||||||
|
pub use requests::{handle_friend_accept, handle_friend_request};
|
||||||
152
gateway/src/handler/friends/requests.rs
Normal file
152
gateway/src/handler/friends/requests.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{
|
||||||
|
io,
|
||||||
|
state::{BroadcastMsg, State},
|
||||||
|
},
|
||||||
|
proto::{
|
||||||
|
FriendAcceptPayload, FriendEventPayload, FriendRequestPayload, PacketId, SessionCrypto,
|
||||||
|
encode_packet, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::send_err;
|
||||||
|
|
||||||
|
pub async fn handle_friend_request(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: FriendRequestPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
if state
|
||||||
|
.storage
|
||||||
|
.is_blocked(&req.to_user_id, &sess.user_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::Blocked,
|
||||||
|
"you are blocked by this user",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if state
|
||||||
|
.storage
|
||||||
|
.is_friend(&sess.user_id, &req.to_user_id)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::InvalidPacket,
|
||||||
|
"already friends",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ok = state
|
||||||
|
.storage
|
||||||
|
.create_friend_request(&sess.user_id, &req.to_user_id)
|
||||||
|
.await?;
|
||||||
|
if !ok {
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::InvalidPacket,
|
||||||
|
"already friends or request pending",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ev = FriendEventPayload {
|
||||||
|
event: "REQUEST_RECEIVED".into(),
|
||||||
|
user_id: sess.user_id.clone(),
|
||||||
|
nickname: Some(sess.nickname.clone()),
|
||||||
|
};
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: None,
|
||||||
|
exclude_session: None,
|
||||||
|
target_session_id: Some(req.to_user_id.clone()),
|
||||||
|
data: encode_packet(PacketId::FriendRequest, *seq, &to_payload(&ev)),
|
||||||
|
});
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::FriendRequest,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"status": "PENDING", "to_user_id": req.to_user_id})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_friend_accept(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: FriendAcceptPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let ok = state
|
||||||
|
.storage
|
||||||
|
.accept_friend_request(&req.from_user_id, &sess.user_id)
|
||||||
|
.await?;
|
||||||
|
if !ok {
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::InvalidPacket,
|
||||||
|
"no pending request",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let ev = FriendEventPayload {
|
||||||
|
event: "REQUEST_ACCEPTED".into(),
|
||||||
|
user_id: sess.user_id.clone(),
|
||||||
|
nickname: Some(sess.nickname.clone()),
|
||||||
|
};
|
||||||
|
let _ = state.broadcast.send(BroadcastMsg {
|
||||||
|
channel_id: None,
|
||||||
|
exclude_session: None,
|
||||||
|
target_session_id: Some(req.from_user_id.clone()),
|
||||||
|
data: encode_packet(PacketId::FriendAccept, *seq, &to_payload(&ev)),
|
||||||
|
});
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::FriendAccept,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"status": "ACCEPTED", "from_user_id": req.from_user_id})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
86
gateway/src/handler/guild/audit.rs
Normal file
86
gateway/src/handler/guild/audit.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::permissions::Permissions,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{
|
||||||
|
AuditLogEntryPayload, GuildAuditLogFetchPayload, GuildAuditLogPayload, PacketId,
|
||||||
|
SessionCrypto, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Fetch audit log entries for a guild (admin-only: requires VIEW_AUDIT_LOG or owner).
|
||||||
|
pub async fn handle_audit_log_fetch(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: GuildAuditLogFetchPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match crate::domain::session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Permission check: owner always passes; otherwise require VIEW_AUDIT_LOG.
|
||||||
|
// We reuse the existing helper via the same logic.
|
||||||
|
let allowed = if let Some(g) = state.storage.get_guild(&req.guild_id).await?
|
||||||
|
&& g.owner_id == sess.user_id
|
||||||
|
{
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
// No VIEW_AUDIT_LOG bit defined yet — fall back to MANAGE_GUILD.
|
||||||
|
let perms = state
|
||||||
|
.storage
|
||||||
|
.get_user_role_perms(&req.guild_id, &sess.user_id)
|
||||||
|
.await?;
|
||||||
|
Permissions::from_role_perms(&perms).has(Permissions::MANAGE_GUILD)
|
||||||
|
};
|
||||||
|
|
||||||
|
if !allowed {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&crate::proto::ErrorPayload {
|
||||||
|
code: crate::proto::ErrorCode::PermissionDenied as u32,
|
||||||
|
message: "audit log access requires MANAGE_GUILD".into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let limit = req.limit.clamp(1, 200);
|
||||||
|
let rows = state.storage.get_audit_log(&req.guild_id, limit).await?;
|
||||||
|
let entries: Vec<AuditLogEntryPayload> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| AuditLogEntryPayload {
|
||||||
|
id: r.id,
|
||||||
|
guild_id: r.guild_id,
|
||||||
|
actor_id: r.actor_id,
|
||||||
|
action: r.action,
|
||||||
|
target_id: r.target_id,
|
||||||
|
target_type: r.target_type,
|
||||||
|
reason: r.reason,
|
||||||
|
created_at: r.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let resp = GuildAuditLogPayload {
|
||||||
|
guild_id: req.guild_id,
|
||||||
|
entries,
|
||||||
|
};
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildAuditLog,
|
||||||
|
seq,
|
||||||
|
&to_payload(&resp),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
124
gateway/src/handler/guild/crud.rs
Normal file
124
gateway/src/handler/guild/crud.rs
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{GuildCreatePayload, GuildInfo, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::send_err;
|
||||||
|
|
||||||
|
pub async fn handle_guild_create(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: GuildCreatePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
if req.name.len() < 2 || req.name.len() > 100 {
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::InvalidPacket,
|
||||||
|
"guild name must be 2-100 chars",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let guild_id = state.storage.create_guild(&sess.user_id, &req.name).await?;
|
||||||
|
let guild = state
|
||||||
|
.storage
|
||||||
|
.get_guild(&guild_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("guild not found after create"))?;
|
||||||
|
|
||||||
|
let info = GuildInfo {
|
||||||
|
id: guild.id.clone(),
|
||||||
|
owner_id: guild.owner_id,
|
||||||
|
name: guild.name,
|
||||||
|
member_count: guild.member_count,
|
||||||
|
created_at: guild.created_at,
|
||||||
|
};
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildCreate,
|
||||||
|
seq,
|
||||||
|
&to_payload(&info),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"guild: {} created {} (owner={})",
|
||||||
|
sess.nickname, guild_id, sess.user_id
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_guild_delete(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
struct Req {
|
||||||
|
guild_id: String,
|
||||||
|
}
|
||||||
|
let req: Req = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let guild = state
|
||||||
|
.storage
|
||||||
|
.get_guild(&req.guild_id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("guild not found"))?;
|
||||||
|
if guild.owner_id != sess.user_id {
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"only owner can delete guild",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.storage.delete_guild(&req.guild_id).await?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"GUILD_DELETE",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildDelete,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"guild_id": req.guild_id})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
219
gateway/src/handler/guild/invites.rs
Normal file
219
gateway/src/handler/guild/invites.rs
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{permissions::Permissions, session},
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{
|
||||||
|
InviteAcceptPayload, InviteCreatePayload, InviteDeletePayload, InviteInfo, PacketId,
|
||||||
|
SessionCrypto, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{now_ms, send_err};
|
||||||
|
|
||||||
|
pub async fn handle_invite_create(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: InviteCreatePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
if !super::require_perm(
|
||||||
|
state,
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
Permissions::CREATE_INVITE,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"missing CREATE_INVITE permission",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let inv = state
|
||||||
|
.storage
|
||||||
|
.create_invite(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
req.max_uses,
|
||||||
|
req.expires_in_seconds,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"INVITE_CREATE",
|
||||||
|
Some(&inv.id),
|
||||||
|
Some("invite"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let info = InviteInfo {
|
||||||
|
id: inv.id,
|
||||||
|
guild_id: inv.guild_id,
|
||||||
|
guild_name: inv.guild_name,
|
||||||
|
code: inv.code,
|
||||||
|
creator_id: inv.creator_id,
|
||||||
|
max_uses: inv.max_uses,
|
||||||
|
uses: inv.uses,
|
||||||
|
expires_at: inv.expires_at,
|
||||||
|
created_at: inv.created_at,
|
||||||
|
};
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::InviteCreate,
|
||||||
|
seq,
|
||||||
|
&to_payload(&info),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_invite_accept(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: InviteAcceptPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let inv = state
|
||||||
|
.storage
|
||||||
|
.get_invite_by_code(&req.code)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("invite not found"))?;
|
||||||
|
|
||||||
|
if let Some(exp) = inv.expires_at {
|
||||||
|
let now = now_ms();
|
||||||
|
if now > exp {
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::InvalidPacket,
|
||||||
|
"invite expired",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(max) = inv.max_uses
|
||||||
|
&& inv.uses >= max
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::InvalidPacket,
|
||||||
|
"invite max uses reached",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.add_guild_member(&inv.guild_id, &sess.user_id)
|
||||||
|
.await?;
|
||||||
|
state.storage.use_invite(&inv.id).await?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&inv.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"MEMBER_JOIN_INVITE",
|
||||||
|
Some(&sess.user_id),
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::InviteAccept,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"guild_id": inv.guild_id, "guild_name": inv.guild_name})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_invite_delete(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: InviteDeletePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
if !super::require_perm(
|
||||||
|
state,
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
Permissions::MANAGE_GUILD,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"missing MANAGE_GUILD permission",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.storage.delete_invite(&req.invite_id).await?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"INVITE_DELETE",
|
||||||
|
Some(&req.invite_id),
|
||||||
|
Some("invite"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::InviteDelete,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"invite_id": req.invite_id})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
61
gateway/src/handler/guild/list.rs
Normal file
61
gateway/src/handler/guild/list.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::session,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{GuildInfo, GuildListPayload, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn handle_guild_list(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let rows = state.storage.list_user_guilds(&sess.user_id).await?;
|
||||||
|
let guilds: Vec<GuildInfo> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|g| GuildInfo {
|
||||||
|
id: g.id.clone(),
|
||||||
|
owner_id: g.owner_id.clone(),
|
||||||
|
name: g.name.clone(),
|
||||||
|
member_count: g.member_count,
|
||||||
|
created_at: g.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for g in &rows {
|
||||||
|
let roles = state.storage.get_user_roles(&g.id, &sess.user_id).await?;
|
||||||
|
let color = roles
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|r| r.position)
|
||||||
|
.map(|r| r.color.clone())
|
||||||
|
.unwrap_or_else(|| "#ffffff".into());
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::UserRoleUpdate,
|
||||||
|
seq,
|
||||||
|
&to_payload(
|
||||||
|
&serde_json::json!({"user_id": sess.user_id, "guild_id": g.id, "color": color}),
|
||||||
|
),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildList,
|
||||||
|
seq,
|
||||||
|
&to_payload(&GuildListPayload { guilds }),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
154
gateway/src/handler/guild/members.rs
Normal file
154
gateway/src/handler/guild/members.rs
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{permissions::Permissions, session},
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{
|
||||||
|
GuildMemberJoinPayload, GuildMemberKickPayload, GuildMemberLeavePayload, PacketId,
|
||||||
|
SessionCrypto, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::send_err;
|
||||||
|
|
||||||
|
pub async fn handle_guild_member_join(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: GuildMemberJoinPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.add_guild_member(&req.guild_id, &sess.user_id)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let roles = state
|
||||||
|
.storage
|
||||||
|
.get_user_roles(&req.guild_id, &sess.user_id)
|
||||||
|
.await?;
|
||||||
|
let color = roles
|
||||||
|
.iter()
|
||||||
|
.max_by_key(|r| r.position)
|
||||||
|
.map(|r| r.color.clone())
|
||||||
|
.unwrap_or_else(|| "#ffffff".into());
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildMemberJoin,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": sess.user_id})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::UserRoleUpdate,
|
||||||
|
seq,
|
||||||
|
&to_payload(
|
||||||
|
&serde_json::json!({"user_id": sess.user_id, "guild_id": req.guild_id, "color": color}),
|
||||||
|
),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_guild_member_leave(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: GuildMemberLeavePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
let target = if req.user_id.is_empty() {
|
||||||
|
&sess.user_id
|
||||||
|
} else {
|
||||||
|
&req.user_id
|
||||||
|
};
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.remove_guild_member(&req.guild_id, target)
|
||||||
|
.await?;
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildMemberLeave,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": target})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_guild_member_kick(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: GuildMemberKickPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
if !super::require_perm(
|
||||||
|
state,
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
Permissions::KICK_MEMBERS,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"missing KICK_MEMBERS permission",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.remove_guild_member(&req.guild_id, &req.user_id)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"MEMBER_KICK",
|
||||||
|
Some(&req.user_id),
|
||||||
|
Some("member"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildMemberKick,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"guild_id": req.guild_id, "user_id": req.user_id})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
230
gateway/src/handler/guild/members_list.rs
Normal file
230
gateway/src/handler/guild/members_list.rs
Normal file
|
|
@ -0,0 +1,230 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{permissions::Permissions, session},
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{
|
||||||
|
GuildMemberInfoPayload, GuildMemberListFetchPayload, GuildMemberListPayload,
|
||||||
|
GuildRoleInfoPayload, GuildRoleListFetchPayload, GuildRoleListPayload, PacketId,
|
||||||
|
RoleAssignPayload, SessionCrypto, to_payload,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{require_perm, send_err};
|
||||||
|
|
||||||
|
/// Fetch the member list for a guild (visible to all members).
|
||||||
|
pub async fn handle_member_list_fetch(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: GuildMemberListFetchPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Optional: verify the requester is a member of the guild.
|
||||||
|
let owner_id = state
|
||||||
|
.storage
|
||||||
|
.get_guild(&req.guild_id)
|
||||||
|
.await?
|
||||||
|
.map(|g| g.owner_id);
|
||||||
|
|
||||||
|
let rows = state.storage.list_guild_members(&req.guild_id).await?;
|
||||||
|
let members: Vec<GuildMemberInfoPayload> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| GuildMemberInfoPayload {
|
||||||
|
user_id: r.user_id.clone(),
|
||||||
|
nickname: r.nickname,
|
||||||
|
joined_at: r.joined_at,
|
||||||
|
role_color: r.role_color,
|
||||||
|
role_name: r.role_name,
|
||||||
|
is_owner: owner_id.as_deref() == Some(&r.user_id),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let _ = sess;
|
||||||
|
let resp = GuildMemberListPayload {
|
||||||
|
guild_id: req.guild_id,
|
||||||
|
members,
|
||||||
|
};
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildMemberList,
|
||||||
|
seq,
|
||||||
|
&to_payload(&resp),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Assign a role to a user (admin only: requires MANAGE_ROLES).
|
||||||
|
pub async fn handle_role_assign(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: RoleAssignPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !require_perm(
|
||||||
|
state,
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
Permissions::MANAGE_ROLES,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"missing MANAGE_ROLES permission",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.assign_role(&req.guild_id, &req.user_id, &req.role_id)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"ROLE_ASSIGN",
|
||||||
|
Some(&req.user_id),
|
||||||
|
Some("user"),
|
||||||
|
Some(&req.role_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Confirm to caller.
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildRoleAssign,
|
||||||
|
seq,
|
||||||
|
&to_payload(
|
||||||
|
&serde_json::json!({"ok": true, "user_id": req.user_id, "role_id": req.role_id}),
|
||||||
|
),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a role from a user (admin only: requires MANAGE_ROLES).
|
||||||
|
pub async fn handle_role_unassign(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: RoleAssignPayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = match session::get(&state.sessions, session_id).await {
|
||||||
|
Some(s) => s,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
if !require_perm(
|
||||||
|
state,
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
Permissions::MANAGE_ROLES,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"missing MANAGE_ROLES permission",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.remove_role_from_user(&req.guild_id, &req.user_id, &req.role_id)
|
||||||
|
.await?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"ROLE_UNASSIGN",
|
||||||
|
Some(&req.user_id),
|
||||||
|
Some("user"),
|
||||||
|
Some(&req.role_id),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildRoleUnassign,
|
||||||
|
seq,
|
||||||
|
&to_payload(
|
||||||
|
&serde_json::json!({"ok": true, "user_id": req.user_id, "role_id": req.role_id}),
|
||||||
|
),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetch all roles defined in a guild (visible to all members).
|
||||||
|
pub async fn handle_role_list_fetch(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: GuildRoleListFetchPayload = serde_json::from_slice(payload)?;
|
||||||
|
let _sess = session::get(&state.sessions, session_id).await;
|
||||||
|
|
||||||
|
let rows = state.storage.list_guild_roles(&req.guild_id).await?;
|
||||||
|
let roles: Vec<GuildRoleInfoPayload> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| GuildRoleInfoPayload {
|
||||||
|
id: r.id,
|
||||||
|
guild_id: r.guild_id,
|
||||||
|
name: r.name,
|
||||||
|
color: r.color,
|
||||||
|
permissions: r.permissions as u64,
|
||||||
|
position: r.position,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let resp = GuildRoleListPayload {
|
||||||
|
guild_id: req.guild_id,
|
||||||
|
roles,
|
||||||
|
};
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::GuildRoleList,
|
||||||
|
seq,
|
||||||
|
&to_payload(&resp),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
68
gateway/src/handler/guild/mod.rs
Normal file
68
gateway/src/handler/guild/mod.rs
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
mod audit;
|
||||||
|
mod crud;
|
||||||
|
mod invites;
|
||||||
|
mod list;
|
||||||
|
mod members;
|
||||||
|
mod members_list;
|
||||||
|
mod roles;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::permissions::Permissions,
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{ErrorPayload, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn send_err(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
code: crate::proto::ErrorCode,
|
||||||
|
msg: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
) -> Result<()> {
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::Error,
|
||||||
|
seq,
|
||||||
|
&to_payload(&ErrorPayload {
|
||||||
|
code: code as u32,
|
||||||
|
message: msg.into(),
|
||||||
|
}),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn require_perm(
|
||||||
|
state: &State,
|
||||||
|
guild_id: &str,
|
||||||
|
user_id: &str,
|
||||||
|
required: Permissions,
|
||||||
|
) -> Result<bool> {
|
||||||
|
if let Some(g) = state.storage.get_guild(guild_id).await?
|
||||||
|
&& g.owner_id == user_id
|
||||||
|
{
|
||||||
|
return Ok(true);
|
||||||
|
}
|
||||||
|
let perms = state.storage.get_user_role_perms(guild_id, user_id).await?;
|
||||||
|
Ok(Permissions::from_role_perms(&perms).has(required))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn now_ms() -> i64 {
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.as_millis() as i64
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use audit::handle_audit_log_fetch;
|
||||||
|
pub use crud::{handle_guild_create, handle_guild_delete};
|
||||||
|
pub use invites::{handle_invite_accept, handle_invite_create, handle_invite_delete};
|
||||||
|
pub use list::handle_guild_list;
|
||||||
|
pub use members::{handle_guild_member_join, handle_guild_member_kick, handle_guild_member_leave};
|
||||||
|
pub use members_list::{
|
||||||
|
handle_member_list_fetch, handle_role_assign, handle_role_list_fetch, handle_role_unassign,
|
||||||
|
};
|
||||||
|
pub use roles::{handle_role_create, handle_role_delete};
|
||||||
128
gateway/src/handler/guild/roles.rs
Normal file
128
gateway/src/handler/guild/roles.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{permissions::Permissions, session},
|
||||||
|
net::{io, state::State},
|
||||||
|
proto::{PacketId, RoleCreatePayload, RoleDeletePayload, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::send_err;
|
||||||
|
|
||||||
|
pub async fn handle_role_create(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: RoleCreatePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
if !super::require_perm(
|
||||||
|
state,
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
Permissions::MANAGE_ROLES,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"missing MANAGE_ROLES permission",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let color = req.color.as_deref().unwrap_or("#ffffff");
|
||||||
|
let permissions = req.permissions.unwrap_or(0);
|
||||||
|
let role_id = state
|
||||||
|
.storage
|
||||||
|
.create_role(&req.guild_id, &req.name, color, permissions, 1)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"ROLE_CREATE",
|
||||||
|
Some(&role_id),
|
||||||
|
Some("role"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::RoleCreate,
|
||||||
|
seq,
|
||||||
|
&to_payload(
|
||||||
|
&serde_json::json!({"id": role_id, "guild_id": req.guild_id, "name": req.name}),
|
||||||
|
),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn handle_role_delete(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let req: RoleDeletePayload = serde_json::from_slice(payload)?;
|
||||||
|
let sess = session::get(&state.sessions, session_id)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("session not found"))?;
|
||||||
|
|
||||||
|
if !super::require_perm(
|
||||||
|
state,
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
Permissions::MANAGE_ROLES,
|
||||||
|
)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
send_err(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crate::proto::ErrorCode::PermissionDenied,
|
||||||
|
"missing MANAGE_ROLES permission",
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
state.storage.delete_role(&req.role_id).await?;
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.append_audit_log(
|
||||||
|
&req.guild_id,
|
||||||
|
&sess.user_id,
|
||||||
|
"ROLE_DELETE",
|
||||||
|
Some(&req.role_id),
|
||||||
|
Some("role"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
io::send_encrypted(
|
||||||
|
stream,
|
||||||
|
PacketId::RoleDelete,
|
||||||
|
seq,
|
||||||
|
&to_payload(&serde_json::json!({"role_id": req.role_id})),
|
||||||
|
crypto,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
22
gateway/src/handler/mod.rs
Normal file
22
gateway/src/handler/mod.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
pub mod channel;
|
||||||
|
pub mod content;
|
||||||
|
pub mod deliver;
|
||||||
|
pub mod direct_message;
|
||||||
|
pub mod dispatch;
|
||||||
|
pub mod friends;
|
||||||
|
pub mod guild;
|
||||||
|
pub mod run;
|
||||||
|
|
||||||
|
pub use dispatch::dispatch;
|
||||||
|
pub use run::run_session;
|
||||||
|
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::{net::state::State, proto::SessionCrypto};
|
||||||
|
|
||||||
|
pub struct Ctx<'a> {
|
||||||
|
pub stream: &'a mut TcpStream,
|
||||||
|
pub seq: &'a mut u32,
|
||||||
|
pub crypto: &'a SessionCrypto,
|
||||||
|
pub state: &'a State,
|
||||||
|
}
|
||||||
55
gateway/src/handler/run.rs
Normal file
55
gateway/src/handler/run.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::warn;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
net::{io, state::BroadcastMsg, state::State},
|
||||||
|
proto::{self, PacketId, SessionCrypto, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{Ctx, deliver::deliver_encrypted, dispatch};
|
||||||
|
|
||||||
|
pub async fn run_session(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
seq: &mut u32,
|
||||||
|
session_id: &str,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
state: &State,
|
||||||
|
bcast_rx: &mut broadcast::Receiver<BroadcastMsg>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut ctx = Ctx {
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
crypto,
|
||||||
|
state,
|
||||||
|
};
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
result = io::read_encrypted(ctx.stream, ctx.crypto) => {
|
||||||
|
let (hdr, payload) = result?;
|
||||||
|
let pid = match PacketId::from_u16(hdr.packet_id) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
warn!("{addr} unknown 0x{:04X}", hdr.packet_id);
|
||||||
|
io::send_encrypted(ctx.stream, PacketId::Error, ctx.seq, &to_payload(&proto::ErrorPayload {
|
||||||
|
code: proto::ErrorCode::InvalidPacket as u32,
|
||||||
|
message: "unknown packet".into(),
|
||||||
|
}), ctx.crypto).await?;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
dispatch(&mut ctx, session_id, pid, &payload, addr).await?;
|
||||||
|
if pid == PacketId::Disconnect { return Ok(()); }
|
||||||
|
}
|
||||||
|
bcast = bcast_rx.recv() => {
|
||||||
|
match bcast {
|
||||||
|
Ok(msg) => deliver_encrypted(ctx.stream, ctx.seq, session_id, &msg, ctx.crypto, ctx.state).await?,
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => warn!("{addr} lagged {n}"),
|
||||||
|
Err(broadcast::error::RecvError::Closed) => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
166
gateway/src/lib.rs
Normal file
166
gateway/src/lib.rs
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
pub mod admin;
|
||||||
|
pub mod bootstrap;
|
||||||
|
pub mod domain;
|
||||||
|
pub mod handler;
|
||||||
|
pub mod net;
|
||||||
|
pub mod proto;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use tokio::net::TcpListener;
|
||||||
|
use tokio::sync::broadcast;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use domain::{channels, config, session, storage};
|
||||||
|
use net::state::State;
|
||||||
|
|
||||||
|
pub async fn run(cfg: Arc<config::Config>) -> Result<()> {
|
||||||
|
let private_mode = cfg.is_private();
|
||||||
|
if private_mode {
|
||||||
|
info!("private mode enabled, federation disabled");
|
||||||
|
} else {
|
||||||
|
info!("federation enabled");
|
||||||
|
}
|
||||||
|
info!(
|
||||||
|
"VNOX Gateway — node: {} addr: {} bind: {}",
|
||||||
|
cfg.node.name, cfg.node.address, cfg.gateway.bind
|
||||||
|
);
|
||||||
|
if let Some(limit) = cfg.gateway.max_connections {
|
||||||
|
info!("max_connections configured: {limit} (not enforced yet)");
|
||||||
|
}
|
||||||
|
match cfg.storage.backend.as_deref().unwrap_or("sqlite") {
|
||||||
|
"sqlite" => {
|
||||||
|
if cfg.storage.postgres_url.is_some() {
|
||||||
|
warn!("storage.postgres_url is set but backend=sqlite; postgres_url is ignored");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"postgres" => {
|
||||||
|
warn!("backend=postgres is not implemented yet; sqlite storage will be used");
|
||||||
|
if cfg.storage.postgres_url.is_none() {
|
||||||
|
warn!("backend=postgres configured but storage.postgres_url is missing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
other => warn!("unknown storage backend '{other}'; sqlite storage will be used"),
|
||||||
|
}
|
||||||
|
|
||||||
|
let sqlite = cfg
|
||||||
|
.storage
|
||||||
|
.sqlite_path
|
||||||
|
.as_ref()
|
||||||
|
.map(|p| p.to_string_lossy().into_owned())
|
||||||
|
.unwrap_or_else(|| "./dev/data/vnox.db".into());
|
||||||
|
|
||||||
|
let server_identity = Arc::new(
|
||||||
|
bootstrap::server_identity::ServerIdentity::load_or_generate(&cfg.storage.data_dir)?,
|
||||||
|
);
|
||||||
|
let server_pubkey = server_identity.pubkey_hex();
|
||||||
|
info!(
|
||||||
|
"server identity: {}…",
|
||||||
|
&server_pubkey[..8.min(server_pubkey.len())]
|
||||||
|
);
|
||||||
|
|
||||||
|
let (tx, _) = broadcast::channel(256);
|
||||||
|
let metrics = admin::metrics::Metrics::new();
|
||||||
|
let sessions_count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let channels_count = Arc::new(AtomicUsize::new(0));
|
||||||
|
let state = State::new(
|
||||||
|
session::new_store(),
|
||||||
|
channels::new_store(),
|
||||||
|
Arc::new(storage::Storage::connect(&sqlite).await?),
|
||||||
|
cfg.clone(),
|
||||||
|
server_identity,
|
||||||
|
tx,
|
||||||
|
metrics.clone(),
|
||||||
|
sessions_count.clone(),
|
||||||
|
channels_count.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
|
let admin_bind = cfg
|
||||||
|
.gateway
|
||||||
|
.admin_bind
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| "0.0.0.0:7601".to_string());
|
||||||
|
let admin_state = admin::AdminState {
|
||||||
|
started_at: std::time::Instant::now(),
|
||||||
|
node_name: cfg.node.name.clone(),
|
||||||
|
node_address: cfg.node.address.clone(),
|
||||||
|
private_mode,
|
||||||
|
metrics: metrics.clone(),
|
||||||
|
sessions_count,
|
||||||
|
channels_count,
|
||||||
|
};
|
||||||
|
tokio::spawn(admin::run(admin_bind, admin_state));
|
||||||
|
|
||||||
|
let listener = TcpListener::bind(&cfg.gateway.bind).await?;
|
||||||
|
info!("listening on {}", cfg.gateway.bind);
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match listener.accept().await {
|
||||||
|
Ok((stream, addr)) => {
|
||||||
|
info!("connection from {addr}");
|
||||||
|
metrics.inc(&metrics.connections_total);
|
||||||
|
let s = state.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle(stream, addr, s).await {
|
||||||
|
debug!("{addr} closed: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => error!("accept: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle(
|
||||||
|
mut stream: tokio::net::TcpStream,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
state: State,
|
||||||
|
) -> Result<()> {
|
||||||
|
let mut seq = 0u32;
|
||||||
|
let (sess, crypto) = net::handshake::run(&mut stream, addr, &state, &mut seq).await?;
|
||||||
|
let sid = sess.session_id.clone();
|
||||||
|
state
|
||||||
|
.sessions_count
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
let mut bcast_rx = state.broadcast.subscribe();
|
||||||
|
|
||||||
|
let result = handler::run_session(
|
||||||
|
&mut stream,
|
||||||
|
addr,
|
||||||
|
&mut seq,
|
||||||
|
&sid,
|
||||||
|
&crypto,
|
||||||
|
&state,
|
||||||
|
&mut bcast_rx,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
if result.is_err() {
|
||||||
|
let p = proto::DisconnectPayload {
|
||||||
|
reason: "session closed".into(),
|
||||||
|
};
|
||||||
|
let _ = net::io::send_encrypted(
|
||||||
|
&mut stream,
|
||||||
|
proto::PacketId::Disconnect,
|
||||||
|
&mut seq,
|
||||||
|
&proto::to_payload(&p),
|
||||||
|
&crypto,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ch) = session::get(&state.sessions, &sid)
|
||||||
|
.await
|
||||||
|
.and_then(|s| s.channel_id)
|
||||||
|
{
|
||||||
|
channels::leave(&state.channels, &ch, &sid).await;
|
||||||
|
handler::channel::broadcast_leave(&state, &ch, &sid).await;
|
||||||
|
}
|
||||||
|
session::remove(&state.sessions, &sid).await;
|
||||||
|
state
|
||||||
|
.sessions_count
|
||||||
|
.fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
|
||||||
|
state.rate_limiter.remove(&sid);
|
||||||
|
debug!("{addr} cleaned up");
|
||||||
|
result
|
||||||
|
}
|
||||||
12
gateway/src/main.rs
Normal file
12
gateway/src/main.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cfg = Arc::new(vnox_gateway::domain::config::load()?);
|
||||||
|
vnox_gateway::run(cfg).await
|
||||||
|
}
|
||||||
140
gateway/src/net/handshake.rs
Normal file
140
gateway/src/net/handshake.rs
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
domain::{auth, session},
|
||||||
|
net::io,
|
||||||
|
net::state::State,
|
||||||
|
proto::{self, AuthPayload, HelloPayload, PacketId, SessionCrypto, SessionPayload, to_payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
const LNEX_VERSION: &str = "v1";
|
||||||
|
|
||||||
|
/// Run HELLO → AUTH → SESSION with X25519 ECDH key exchange.
|
||||||
|
///
|
||||||
|
/// Returns the session and the derived crypto context (encryption keys).
|
||||||
|
/// All subsequent packets must be encrypted with `crypto`.
|
||||||
|
pub async fn run(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
addr: std::net::SocketAddr,
|
||||||
|
state: &State,
|
||||||
|
seq: &mut u32,
|
||||||
|
) -> Result<(session::Session, SessionCrypto)> {
|
||||||
|
// Generate ephemeral X25519 keypair for forward secrecy
|
||||||
|
let (eph_sk, eph_pk) = SessionCrypto::new_ephemeral();
|
||||||
|
let eph_pk_hex = hex::encode(eph_pk.as_bytes());
|
||||||
|
|
||||||
|
// HELLO — include server's ephemeral public key + privacy mode
|
||||||
|
let challenge = auth::new_challenge();
|
||||||
|
let private_mode = state.config.is_private();
|
||||||
|
let hello = HelloPayload {
|
||||||
|
lnex_version: LNEX_VERSION.into(),
|
||||||
|
server_pubkey: state.server_identity.pubkey_hex(),
|
||||||
|
challenge_nonce: hex::encode(challenge),
|
||||||
|
node_name: state.config.node.name.clone(),
|
||||||
|
server_eph_pubkey: eph_pk_hex,
|
||||||
|
private_mode,
|
||||||
|
};
|
||||||
|
io::send_packet(stream, PacketId::Hello, seq, &to_payload(&hello)).await?;
|
||||||
|
debug!("{addr} ← HELLO (eph key included)");
|
||||||
|
|
||||||
|
// AUTH — receive client's ephemeral public key
|
||||||
|
let (hdr, payload) = io::read_packet(stream).await?;
|
||||||
|
if PacketId::from_u16(hdr.packet_id) != Some(PacketId::Auth) {
|
||||||
|
io::send_error(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
proto::ErrorCode::InvalidPacket,
|
||||||
|
"expected AUTH",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Err(anyhow::anyhow!("expected AUTH"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let msg: AuthPayload = serde_json::from_slice(&payload)?;
|
||||||
|
debug!("{addr} → AUTH nick={}", msg.nickname);
|
||||||
|
|
||||||
|
if msg.lnex_version != LNEX_VERSION {
|
||||||
|
io::send_error(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
proto::ErrorCode::VersionMismatch,
|
||||||
|
"unsupported version",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Err(anyhow::anyhow!("version mismatch"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pubkey =
|
||||||
|
hex_to_32(&msg.client_pubkey).ok_or_else(|| anyhow::anyhow!("bad client pubkey"))?;
|
||||||
|
let sig = hex_to_64(&msg.signature).ok_or_else(|| anyhow::anyhow!("bad signature"))?;
|
||||||
|
|
||||||
|
if let Err(e) = auth::verify_auth(&challenge, &pubkey, &sig) {
|
||||||
|
warn!("{addr} auth failed: {e}");
|
||||||
|
state.metrics.inc(&state.metrics.auth_failures);
|
||||||
|
io::send_error(
|
||||||
|
stream,
|
||||||
|
seq,
|
||||||
|
proto::ErrorCode::AuthFailed,
|
||||||
|
"invalid signature",
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
return Err(anyhow::anyhow!("auth failed"));
|
||||||
|
}
|
||||||
|
|
||||||
|
if state.storage.is_banned(&msg.client_pubkey).await? {
|
||||||
|
state.metrics.inc(&state.metrics.auth_failures);
|
||||||
|
io::send_error(stream, seq, proto::ErrorCode::AuthFailed, "banned").await?;
|
||||||
|
return Err(anyhow::anyhow!("banned"));
|
||||||
|
}
|
||||||
|
|
||||||
|
state
|
||||||
|
.storage
|
||||||
|
.upsert_user(&msg.client_pubkey, &msg.nickname)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// ECDH: compute shared secret from server's ephemeral sk + client's ephemeral pk
|
||||||
|
let client_eph_raw = hex_to_32(&msg.client_eph_pubkey)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("bad client eph pubkey"))?;
|
||||||
|
let client_eph_pk = x25519_dalek::PublicKey::from(client_eph_raw);
|
||||||
|
let shared_secret = SessionCrypto::ecdh(eph_sk, &client_eph_pk);
|
||||||
|
|
||||||
|
// SESSION
|
||||||
|
let sess = session::create(
|
||||||
|
&state.sessions,
|
||||||
|
msg.client_pubkey.clone(),
|
||||||
|
msg.nickname.clone(),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Derive encryption keys from shared secret + session_id
|
||||||
|
let crypto = SessionCrypto::derive(shared_secret.as_bytes(), &sess.session_id);
|
||||||
|
|
||||||
|
let timeout_secs = state.config.gateway.session_timeout.unwrap_or(600);
|
||||||
|
let expires_at = std::time::SystemTime::now()
|
||||||
|
.checked_add(std::time::Duration::from_secs(timeout_secs))
|
||||||
|
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let sp = SessionPayload {
|
||||||
|
session_id: sess.session_id.clone(),
|
||||||
|
token: sess.token.clone(),
|
||||||
|
expires_at,
|
||||||
|
};
|
||||||
|
io::send_packet(stream, PacketId::Session, seq, &to_payload(&sp)).await?;
|
||||||
|
info!(
|
||||||
|
"{addr} auth ok — nick={} sid={} encrypted",
|
||||||
|
sess.nickname,
|
||||||
|
&sess.session_id[..8]
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok((sess, crypto))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hex_to_32(s: &str) -> Option<[u8; 32]> {
|
||||||
|
hex::decode(s).ok()?.try_into().ok()
|
||||||
|
}
|
||||||
|
fn hex_to_64(s: &str) -> Option<[u8; 64]> {
|
||||||
|
hex::decode(s).ok()?.try_into().ok()
|
||||||
|
}
|
||||||
135
gateway/src/net/io.rs
Normal file
135
gateway/src/net/io.rs
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
|
use tokio::net::TcpStream;
|
||||||
|
|
||||||
|
use crate::proto::{
|
||||||
|
ErrorCode, ErrorPayload, PacketHeader, PacketId, SessionCrypto, encode_packet, flags,
|
||||||
|
to_payload,
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_PAYLOAD: u32 = 4 * 1024 * 1024;
|
||||||
|
|
||||||
|
// ─── Unencrypted IO (used during handshake) ─────────────────────────────────
|
||||||
|
|
||||||
|
pub async fn send_packet(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
id: PacketId,
|
||||||
|
seq: &mut u32,
|
||||||
|
payload: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
let data = encode_packet(id, *seq, payload);
|
||||||
|
*seq = seq.wrapping_add(1);
|
||||||
|
stream.write_all(&data).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_error(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
seq: &mut u32,
|
||||||
|
code: ErrorCode,
|
||||||
|
msg: &str,
|
||||||
|
) -> Result<()> {
|
||||||
|
let p = ErrorPayload {
|
||||||
|
code: code as u32,
|
||||||
|
message: msg.into(),
|
||||||
|
};
|
||||||
|
debug_assert!(ErrorCode::from_u32(p.code).is_some());
|
||||||
|
send_packet(stream, PacketId::Error, seq, &to_payload(&p)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn read_packet(stream: &mut TcpStream) -> Result<(PacketHeader, Vec<u8>)> {
|
||||||
|
let mut buf = [0u8; PacketHeader::SIZE];
|
||||||
|
stream.read_exact(&mut buf).await?;
|
||||||
|
let hdr = PacketHeader::from_bytes(&buf);
|
||||||
|
if hdr.flags & !flags::KNOWN_MASK != 0 {
|
||||||
|
return Err(anyhow::anyhow!("unknown packet flags: 0x{:04X}", hdr.flags));
|
||||||
|
}
|
||||||
|
if hdr.payload_length > MAX_PAYLOAD {
|
||||||
|
return Err(anyhow::anyhow!("payload too large: {}", hdr.payload_length));
|
||||||
|
}
|
||||||
|
let mut payload = vec![0u8; hdr.payload_length as usize];
|
||||||
|
if !payload.is_empty() {
|
||||||
|
stream.read_exact(&mut payload).await?;
|
||||||
|
}
|
||||||
|
Ok((hdr, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Encrypted IO (used after handshake) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/// Send an encrypted packet (server → client).
|
||||||
|
/// Sets the ENCRYPTED flag and encrypts the payload with s2c_key.
|
||||||
|
pub async fn send_encrypted(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
id: PacketId,
|
||||||
|
seq: &mut u32,
|
||||||
|
payload: &[u8],
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
) -> Result<()> {
|
||||||
|
let encrypted = crypto.encrypt_s2c(*seq as u64, payload);
|
||||||
|
let flags_val = flags::ENCRYPTED;
|
||||||
|
let mut hdr_buf = [0u8; PacketHeader::SIZE];
|
||||||
|
hdr_buf[0..2].copy_from_slice(&(id as u16).to_be_bytes());
|
||||||
|
hdr_buf[2..4].copy_from_slice(&flags_val.to_be_bytes());
|
||||||
|
hdr_buf[4..8].copy_from_slice(&seq.to_be_bytes());
|
||||||
|
hdr_buf[8..12].copy_from_slice(&(encrypted.len() as u32).to_be_bytes());
|
||||||
|
*seq = seq.wrapping_add(1);
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(PacketHeader::SIZE + encrypted.len());
|
||||||
|
out.extend_from_slice(&hdr_buf);
|
||||||
|
out.extend_from_slice(&encrypted);
|
||||||
|
stream.write_all(&out).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read and decrypt a packet (client → server).
|
||||||
|
/// Verifies the ENCRYPTED flag and decrypts with c2s_key.
|
||||||
|
pub async fn read_encrypted(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
) -> Result<(PacketHeader, Vec<u8>)> {
|
||||||
|
let mut buf = [0u8; PacketHeader::SIZE];
|
||||||
|
stream.read_exact(&mut buf).await?;
|
||||||
|
let hdr = PacketHeader::from_bytes(&buf);
|
||||||
|
if hdr.flags & !flags::KNOWN_MASK != 0 {
|
||||||
|
return Err(anyhow::anyhow!("unknown packet flags: 0x{:04X}", hdr.flags));
|
||||||
|
}
|
||||||
|
if hdr.flags & flags::ENCRYPTED == 0 {
|
||||||
|
return Err(anyhow::anyhow!("packet not encrypted"));
|
||||||
|
}
|
||||||
|
if hdr.payload_length > MAX_PAYLOAD {
|
||||||
|
return Err(anyhow::anyhow!("payload too large: {}", hdr.payload_length));
|
||||||
|
}
|
||||||
|
let mut encrypted = vec![0u8; hdr.payload_length as usize];
|
||||||
|
if !encrypted.is_empty() {
|
||||||
|
stream.read_exact(&mut encrypted).await?;
|
||||||
|
}
|
||||||
|
let payload = crypto.decrypt_c2s(hdr.sequence as u64, &encrypted)?;
|
||||||
|
Ok((hdr, payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-encode and deliver a pre-encoded broadcast packet with encryption.
|
||||||
|
///
|
||||||
|
/// Extracts the packet ID from the pre-encoded `raw_data`, re-encodes
|
||||||
|
/// with the correct sequence and encryption for this recipient.
|
||||||
|
pub async fn deliver_encrypted(
|
||||||
|
stream: &mut TcpStream,
|
||||||
|
crypto: &SessionCrypto,
|
||||||
|
seq: &mut u32,
|
||||||
|
raw_data: &[u8],
|
||||||
|
) -> Result<()> {
|
||||||
|
if raw_data.len() < PacketHeader::SIZE {
|
||||||
|
return Err(anyhow::anyhow!("deliver: packet too short"));
|
||||||
|
}
|
||||||
|
let raw_hdr = PacketHeader::from_bytes(raw_data[..PacketHeader::SIZE].try_into().unwrap());
|
||||||
|
let pid = match PacketId::from_u16(raw_hdr.packet_id) {
|
||||||
|
Some(p) => p,
|
||||||
|
None => {
|
||||||
|
return Err(anyhow::anyhow!(
|
||||||
|
"deliver: unknown packet id 0x{:04X}",
|
||||||
|
raw_hdr.packet_id
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let payload = &raw_data[PacketHeader::SIZE..];
|
||||||
|
send_encrypted(stream, pid, seq, payload, crypto).await
|
||||||
|
}
|
||||||
3
gateway/src/net/mod.rs
Normal file
3
gateway/src/net/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
pub mod handshake;
|
||||||
|
pub mod io;
|
||||||
|
pub mod state;
|
||||||
73
gateway/src/net/state.rs
Normal file
73
gateway/src/net/state.rs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::AtomicUsize;
|
||||||
|
use tokio::sync::{RwLock, broadcast};
|
||||||
|
|
||||||
|
use crate::admin::metrics::Metrics;
|
||||||
|
use crate::bootstrap::server_identity::ServerIdentity;
|
||||||
|
use crate::domain::rate_limit::RateLimiter;
|
||||||
|
use crate::domain::{
|
||||||
|
channels::ChannelStore, config::Config, session::SessionStore, storage::Storage,
|
||||||
|
};
|
||||||
|
use crate::proto::PresenceInfo;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct State {
|
||||||
|
pub sessions: SessionStore,
|
||||||
|
pub channels: ChannelStore,
|
||||||
|
pub storage: Arc<Storage>,
|
||||||
|
pub config: Arc<Config>,
|
||||||
|
pub server_identity: Arc<ServerIdentity>,
|
||||||
|
pub broadcast: broadcast::Sender<BroadcastMsg>,
|
||||||
|
/// user_id → presence info (Phase 1.2)
|
||||||
|
pub presences: Arc<RwLock<HashMap<String, PresenceInfo>>>,
|
||||||
|
/// Prometheus-style counters.
|
||||||
|
pub metrics: Arc<Metrics>,
|
||||||
|
/// Live count of authenticated sessions.
|
||||||
|
pub sessions_count: Arc<AtomicUsize>,
|
||||||
|
/// Live count of channels with at least one member.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub channels_count: Arc<AtomicUsize>,
|
||||||
|
/// Per-session rate limiter (token bucket).
|
||||||
|
pub rate_limiter: Arc<RateLimiter>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl State {
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn new(
|
||||||
|
sessions: SessionStore,
|
||||||
|
channels: ChannelStore,
|
||||||
|
storage: Arc<Storage>,
|
||||||
|
config: Arc<Config>,
|
||||||
|
server_identity: Arc<ServerIdentity>,
|
||||||
|
broadcast: broadcast::Sender<BroadcastMsg>,
|
||||||
|
metrics: Arc<Metrics>,
|
||||||
|
sessions_count: Arc<AtomicUsize>,
|
||||||
|
channels_count: Arc<AtomicUsize>,
|
||||||
|
) -> Self {
|
||||||
|
let rate_per_sec = config.gateway.message_rate_per_sec.unwrap_or(5.0);
|
||||||
|
let burst = config.gateway.message_rate_burst.unwrap_or(10);
|
||||||
|
let rate_limiter = Arc::new(RateLimiter::new(rate_per_sec, burst));
|
||||||
|
Self {
|
||||||
|
sessions,
|
||||||
|
channels,
|
||||||
|
storage,
|
||||||
|
config,
|
||||||
|
server_identity,
|
||||||
|
broadcast,
|
||||||
|
presences: Arc::new(RwLock::new(HashMap::new())),
|
||||||
|
metrics,
|
||||||
|
sessions_count,
|
||||||
|
channels_count,
|
||||||
|
rate_limiter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct BroadcastMsg {
|
||||||
|
pub channel_id: Option<String>,
|
||||||
|
pub exclude_session: Option<String>,
|
||||||
|
pub target_session_id: Option<String>,
|
||||||
|
pub data: Vec<u8>,
|
||||||
|
}
|
||||||
51
gateway/src/proto/crypto/cipher.rs
Normal file
51
gateway/src/proto/crypto/cipher.rs
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
use chacha20poly1305::{
|
||||||
|
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
||||||
|
aead::{Aead, Payload},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(super) fn make_nonce(cid: &[u8; 8], seq: u64) -> [u8; 12] {
|
||||||
|
let mut nonce = [0u8; 12];
|
||||||
|
nonce[..8].copy_from_slice(cid);
|
||||||
|
nonce[8..12].copy_from_slice(&(seq as u32).to_le_bytes());
|
||||||
|
nonce
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn encrypt_with_key(
|
||||||
|
key: &[u8; 32],
|
||||||
|
cid: &[u8; 8],
|
||||||
|
seq: u64,
|
||||||
|
plaintext: &[u8],
|
||||||
|
) -> Vec<u8> {
|
||||||
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||||
|
let nonce_bytes = make_nonce(cid, seq);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
cipher
|
||||||
|
.encrypt(
|
||||||
|
nonce,
|
||||||
|
Payload {
|
||||||
|
msg: plaintext,
|
||||||
|
aad: &[],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.expect("ChaCha20-Poly1305 encryption is infallible")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn decrypt_with_key(
|
||||||
|
key: &[u8; 32],
|
||||||
|
cid: &[u8; 8],
|
||||||
|
seq: u64,
|
||||||
|
ciphertext: &[u8],
|
||||||
|
) -> anyhow::Result<Vec<u8>> {
|
||||||
|
let cipher = ChaCha20Poly1305::new(Key::from_slice(key));
|
||||||
|
let nonce_bytes = make_nonce(cid, seq);
|
||||||
|
let nonce = Nonce::from_slice(&nonce_bytes);
|
||||||
|
cipher
|
||||||
|
.decrypt(
|
||||||
|
nonce,
|
||||||
|
Payload {
|
||||||
|
msg: ciphertext,
|
||||||
|
aad: &[],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| anyhow::anyhow!("decryption failed: {e:?}"))
|
||||||
|
}
|
||||||
112
gateway/src/proto/crypto/mod.rs
Normal file
112
gateway/src/proto/crypto/mod.rs
Normal file
|
|
@ -0,0 +1,112 @@
|
||||||
|
mod cipher;
|
||||||
|
|
||||||
|
use hkdf::Hkdf;
|
||||||
|
use sha2::Sha256;
|
||||||
|
use x25519_dalek::{EphemeralSecret, PublicKey, SharedSecret};
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct SessionCrypto {
|
||||||
|
c2s_key: [u8; 32],
|
||||||
|
s2c_key: [u8; 32],
|
||||||
|
cid: [u8; 8],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionCrypto {
|
||||||
|
pub fn derive(shared_secret: &[u8; 32], session_id: &str) -> Self {
|
||||||
|
let salt = b"VNOX-LNEx-KDF-v1";
|
||||||
|
let hk = Hkdf::<Sha256>::new(Some(salt), shared_secret);
|
||||||
|
|
||||||
|
let mut keys = [0u8; 64];
|
||||||
|
hk.expand(b"session-keys", &mut keys)
|
||||||
|
.expect("64 bytes is within HKDF max");
|
||||||
|
|
||||||
|
let c2s_key: [u8; 32] = keys[..32].try_into().unwrap();
|
||||||
|
let s2c_key: [u8; 32] = keys[32..64].try_into().unwrap();
|
||||||
|
|
||||||
|
use sha2::Digest;
|
||||||
|
let hash = Sha256::digest(session_id.as_bytes());
|
||||||
|
let cid: [u8; 8] = hash[..8].try_into().unwrap();
|
||||||
|
|
||||||
|
Self {
|
||||||
|
c2s_key,
|
||||||
|
s2c_key,
|
||||||
|
cid,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn encrypt_s2c(&self, seq: u64, plaintext: &[u8]) -> Vec<u8> {
|
||||||
|
cipher::encrypt_with_key(&self.s2c_key, &self.cid, seq, plaintext)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn decrypt_c2s(&self, seq: u64, ciphertext: &[u8]) -> anyhow::Result<Vec<u8>> {
|
||||||
|
cipher::decrypt_with_key(&self.c2s_key, &self.cid, seq, ciphertext)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn new_ephemeral() -> (EphemeralSecret, PublicKey) {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
let sk = EphemeralSecret::random_from_rng(&mut rng);
|
||||||
|
let pk = PublicKey::from(&sk);
|
||||||
|
(sk, pk)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn ecdh(secret: EphemeralSecret, peer_public: &PublicKey) -> SharedSecret {
|
||||||
|
secret.diffie_hellman(peer_public)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encrypt_then_decrypt_roundtrip() {
|
||||||
|
let shared_secret = [0xABu8; 32];
|
||||||
|
let crypto = SessionCrypto::derive(&shared_secret, "test-session-id");
|
||||||
|
|
||||||
|
let plaintext = b"hello encrypted world";
|
||||||
|
let seq = 42;
|
||||||
|
|
||||||
|
let ciphertext = crypto.encrypt_s2c(seq, plaintext);
|
||||||
|
assert_ne!(ciphertext, plaintext);
|
||||||
|
assert!(ciphertext.len() > plaintext.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_keys_cannot_decrypt_each_other() {
|
||||||
|
let shared_secret = [0xABu8; 32];
|
||||||
|
let crypto = SessionCrypto::derive(&shared_secret, "test-session-id");
|
||||||
|
|
||||||
|
let s2c_ct = crypto.encrypt_s2c(0, b"server to client");
|
||||||
|
let result = crypto.decrypt_c2s(0, &s2c_ct);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn different_keys_produce_different_ciphertexts() {
|
||||||
|
let secret_a = [0xAAu8; 32];
|
||||||
|
let secret_b = [0xBBu8; 32];
|
||||||
|
let crypto_a = SessionCrypto::derive(&secret_a, "session-a");
|
||||||
|
let crypto_b = SessionCrypto::derive(&secret_b, "session-b");
|
||||||
|
|
||||||
|
let plaintext = b"same plaintext";
|
||||||
|
let ct_a = crypto_a.encrypt_s2c(0, plaintext);
|
||||||
|
let ct_b = crypto_b.encrypt_s2c(0, plaintext);
|
||||||
|
assert_ne!(ct_a, ct_b);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ecdh_derives_same_secret_on_both_sides() {
|
||||||
|
let mut rng = rand::thread_rng();
|
||||||
|
|
||||||
|
let alice_sk = EphemeralSecret::random_from_rng(&mut rng);
|
||||||
|
let alice_pk = PublicKey::from(&alice_sk);
|
||||||
|
|
||||||
|
let bob_sk = EphemeralSecret::random_from_rng(&mut rng);
|
||||||
|
let bob_pk = PublicKey::from(&bob_sk);
|
||||||
|
|
||||||
|
let alice_shared = alice_sk.diffie_hellman(&bob_pk);
|
||||||
|
let bob_shared = bob_sk.diffie_hellman(&alice_pk);
|
||||||
|
|
||||||
|
assert_eq!(alice_shared.as_bytes(), bob_shared.as_bytes());
|
||||||
|
}
|
||||||
|
}
|
||||||
16
gateway/src/proto/framing.rs
Normal file
16
gateway/src/proto/framing.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
use super::packet::{PacketHeader, PacketId};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
/// Encode a packet: header + JSON payload bytes.
|
||||||
|
pub fn encode_packet(id: PacketId, seq: u32, payload: &[u8]) -> Vec<u8> {
|
||||||
|
let header = PacketHeader::new(id, seq, payload.len() as u32);
|
||||||
|
let mut out = Vec::with_capacity(PacketHeader::SIZE + payload.len());
|
||||||
|
out.extend_from_slice(&header.to_bytes());
|
||||||
|
out.extend_from_slice(payload);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize a payload struct to JSON bytes.
|
||||||
|
pub fn to_payload<T: Serialize>(v: &T) -> Vec<u8> {
|
||||||
|
serde_json::to_vec(v).expect("payload serialization is infallible")
|
||||||
|
}
|
||||||
11
gateway/src/proto/mod.rs
Normal file
11
gateway/src/proto/mod.rs
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
mod crypto;
|
||||||
|
mod framing;
|
||||||
|
mod packet;
|
||||||
|
mod payloads;
|
||||||
|
|
||||||
|
pub use crypto::SessionCrypto;
|
||||||
|
|
||||||
|
// Re-export everything so `crate::proto::X` works as before
|
||||||
|
pub use framing::{encode_packet, to_payload};
|
||||||
|
pub use packet::{ErrorCode, PacketHeader, PacketId, flags};
|
||||||
|
pub use payloads::*;
|
||||||
6
gateway/src/proto/packet/flags.rs
Normal file
6
gateway/src/proto/packet/flags.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
pub const COMPRESSED: u16 = 1 << 0;
|
||||||
|
pub const ENCRYPTED: u16 = 1 << 1;
|
||||||
|
pub const FRAGMENTED: u16 = 1 << 2;
|
||||||
|
pub const LAST_FRAG: u16 = 1 << 3;
|
||||||
|
pub const ACK_REQ: u16 = 1 << 4;
|
||||||
|
pub const KNOWN_MASK: u16 = COMPRESSED | ENCRYPTED | FRAGMENTED | LAST_FRAG | ACK_REQ;
|
||||||
172
gateway/src/proto/packet/id.rs
Normal file
172
gateway/src/proto/packet/id.rs
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
#[repr(u16)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum PacketId {
|
||||||
|
Hello = 0x0001,
|
||||||
|
Auth = 0x0002,
|
||||||
|
Session = 0x0003,
|
||||||
|
Ping = 0x0004,
|
||||||
|
Pong = 0x0005,
|
||||||
|
VoicePacket = 0x0010,
|
||||||
|
VoiceState = 0x0011,
|
||||||
|
ChatMessage = 0x0020,
|
||||||
|
ChatHistory = 0x0021,
|
||||||
|
JoinChannel = 0x0030,
|
||||||
|
LeaveChannel = 0x0031,
|
||||||
|
ChannelState = 0x0032,
|
||||||
|
ChannelCreate = 0x0033,
|
||||||
|
ChannelDelete = 0x0034,
|
||||||
|
ChannelList = 0x0035,
|
||||||
|
UserJoin = 0x0040,
|
||||||
|
UserLeave = 0x0041,
|
||||||
|
PermissionCheck = 0x0050,
|
||||||
|
PermissionDeny = 0x0051,
|
||||||
|
DmStart = 0x0060,
|
||||||
|
DmMessage = 0x0061,
|
||||||
|
DmHistory = 0x0062,
|
||||||
|
DmReadAck = 0x0063,
|
||||||
|
ReadReceipt = 0x0064,
|
||||||
|
ReadReceiptBroadcast = 0x0065,
|
||||||
|
MessageReactionAdd = 0x0066,
|
||||||
|
MessageReactionRemove = 0x0067,
|
||||||
|
MessageEdit = 0x0068,
|
||||||
|
MessageDelete = 0x0069,
|
||||||
|
TypingStart = 0x0070,
|
||||||
|
GuildCreate = 0x0100,
|
||||||
|
GuildDelete = 0x0101,
|
||||||
|
GuildList = 0x0102,
|
||||||
|
GuildMemberJoin = 0x0103,
|
||||||
|
GuildMemberLeave = 0x0104,
|
||||||
|
GuildMemberKick = 0x0105,
|
||||||
|
RoleCreate = 0x0106,
|
||||||
|
RoleDelete = 0x0107,
|
||||||
|
InviteCreate = 0x0108,
|
||||||
|
InviteAccept = 0x0109,
|
||||||
|
InviteDelete = 0x010A,
|
||||||
|
UserRoleUpdate = 0x010B,
|
||||||
|
GuildAuditLogFetch = 0x010C,
|
||||||
|
GuildAuditLog = 0x010D,
|
||||||
|
GuildMemberListFetch = 0x010E,
|
||||||
|
GuildMemberList = 0x010F,
|
||||||
|
GuildRoleAssign = 0x0110,
|
||||||
|
GuildRoleUnassign = 0x0111,
|
||||||
|
GuildRoleListFetch = 0x0112,
|
||||||
|
GuildRoleList = 0x0113,
|
||||||
|
PresenceUpdate = 0x0140,
|
||||||
|
PresenceSync = 0x0141,
|
||||||
|
PresenceEvent = 0x0142,
|
||||||
|
FriendRequest = 0x0150,
|
||||||
|
FriendAccept = 0x0151,
|
||||||
|
FriendDecline = 0x0152,
|
||||||
|
FriendRemove = 0x0153,
|
||||||
|
FriendList = 0x0154,
|
||||||
|
BlockUser = 0x0155,
|
||||||
|
UnblockUser = 0x0156,
|
||||||
|
BlockList = 0x0157,
|
||||||
|
Error = 0x00F0,
|
||||||
|
Disconnect = 0x00FF,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PacketId {
|
||||||
|
pub fn from_u16(v: u16) -> Option<Self> {
|
||||||
|
match v {
|
||||||
|
0x0001 => Some(Self::Hello),
|
||||||
|
0x0002 => Some(Self::Auth),
|
||||||
|
0x0003 => Some(Self::Session),
|
||||||
|
0x0004 => Some(Self::Ping),
|
||||||
|
0x0005 => Some(Self::Pong),
|
||||||
|
0x0010 => Some(Self::VoicePacket),
|
||||||
|
0x0011 => Some(Self::VoiceState),
|
||||||
|
0x0020 => Some(Self::ChatMessage),
|
||||||
|
0x0021 => Some(Self::ChatHistory),
|
||||||
|
0x0030 => Some(Self::JoinChannel),
|
||||||
|
0x0031 => Some(Self::LeaveChannel),
|
||||||
|
0x0032 => Some(Self::ChannelState),
|
||||||
|
0x0033 => Some(Self::ChannelCreate),
|
||||||
|
0x0034 => Some(Self::ChannelDelete),
|
||||||
|
0x0035 => Some(Self::ChannelList),
|
||||||
|
0x0040 => Some(Self::UserJoin),
|
||||||
|
0x0041 => Some(Self::UserLeave),
|
||||||
|
0x0050 => Some(Self::PermissionCheck),
|
||||||
|
0x0051 => Some(Self::PermissionDeny),
|
||||||
|
0x0060 => Some(Self::DmStart),
|
||||||
|
0x0061 => Some(Self::DmMessage),
|
||||||
|
0x0062 => Some(Self::DmHistory),
|
||||||
|
0x0063 => Some(Self::DmReadAck),
|
||||||
|
0x0064 => Some(Self::ReadReceipt),
|
||||||
|
0x0065 => Some(Self::ReadReceiptBroadcast),
|
||||||
|
0x0066 => Some(Self::MessageReactionAdd),
|
||||||
|
0x0067 => Some(Self::MessageReactionRemove),
|
||||||
|
0x0068 => Some(Self::MessageEdit),
|
||||||
|
0x0069 => Some(Self::MessageDelete),
|
||||||
|
0x0070 => Some(Self::TypingStart),
|
||||||
|
0x0100 => Some(Self::GuildCreate),
|
||||||
|
0x0101 => Some(Self::GuildDelete),
|
||||||
|
0x0102 => Some(Self::GuildList),
|
||||||
|
0x0103 => Some(Self::GuildMemberJoin),
|
||||||
|
0x0104 => Some(Self::GuildMemberLeave),
|
||||||
|
0x0105 => Some(Self::GuildMemberKick),
|
||||||
|
0x0106 => Some(Self::RoleCreate),
|
||||||
|
0x0107 => Some(Self::RoleDelete),
|
||||||
|
0x0108 => Some(Self::InviteCreate),
|
||||||
|
0x0109 => Some(Self::InviteAccept),
|
||||||
|
0x010A => Some(Self::InviteDelete),
|
||||||
|
0x010B => Some(Self::UserRoleUpdate),
|
||||||
|
0x010C => Some(Self::GuildAuditLogFetch),
|
||||||
|
0x010D => Some(Self::GuildAuditLog),
|
||||||
|
0x010E => Some(Self::GuildMemberListFetch),
|
||||||
|
0x010F => Some(Self::GuildMemberList),
|
||||||
|
0x0110 => Some(Self::GuildRoleAssign),
|
||||||
|
0x0111 => Some(Self::GuildRoleUnassign),
|
||||||
|
0x0112 => Some(Self::GuildRoleListFetch),
|
||||||
|
0x0113 => Some(Self::GuildRoleList),
|
||||||
|
0x0140 => Some(Self::PresenceUpdate),
|
||||||
|
0x0141 => Some(Self::PresenceSync),
|
||||||
|
0x0142 => Some(Self::PresenceEvent),
|
||||||
|
0x0150 => Some(Self::FriendRequest),
|
||||||
|
0x0151 => Some(Self::FriendAccept),
|
||||||
|
0x0152 => Some(Self::FriendDecline),
|
||||||
|
0x0153 => Some(Self::FriendRemove),
|
||||||
|
0x0154 => Some(Self::FriendList),
|
||||||
|
0x0155 => Some(Self::BlockUser),
|
||||||
|
0x0156 => Some(Self::UnblockUser),
|
||||||
|
0x0157 => Some(Self::BlockList),
|
||||||
|
0x00F0 => Some(Self::Error),
|
||||||
|
0x00FF => Some(Self::Disconnect),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#[repr(u8)]
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ErrorCode {
|
||||||
|
VersionMismatch = 0x01,
|
||||||
|
AuthFailed = 0x02,
|
||||||
|
SessionExpired = 0x03,
|
||||||
|
PermissionDenied = 0x04,
|
||||||
|
ChannelNotFound = 0x05,
|
||||||
|
RateLimited = 0x06,
|
||||||
|
InvalidPacket = 0x07,
|
||||||
|
NodeUnavailable = 0x08,
|
||||||
|
GuildNotFound = 0x09,
|
||||||
|
Blocked = 0x0A,
|
||||||
|
Internal = 0xFF,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ErrorCode {
|
||||||
|
pub fn from_u32(v: u32) -> Option<Self> {
|
||||||
|
match v {
|
||||||
|
0x01 => Some(Self::VersionMismatch),
|
||||||
|
0x02 => Some(Self::AuthFailed),
|
||||||
|
0x03 => Some(Self::SessionExpired),
|
||||||
|
0x04 => Some(Self::PermissionDenied),
|
||||||
|
0x05 => Some(Self::ChannelNotFound),
|
||||||
|
0x06 => Some(Self::RateLimited),
|
||||||
|
0x07 => Some(Self::InvalidPacket),
|
||||||
|
0x08 => Some(Self::NodeUnavailable),
|
||||||
|
0x09 => Some(Self::GuildNotFound),
|
||||||
|
0x0A => Some(Self::Blocked),
|
||||||
|
0xFF => Some(Self::Internal),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
43
gateway/src/proto/packet/mod.rs
Normal file
43
gateway/src/proto/packet/mod.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
pub mod flags;
|
||||||
|
mod id;
|
||||||
|
|
||||||
|
pub use id::{ErrorCode, PacketId};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PacketHeader {
|
||||||
|
pub packet_id: u16,
|
||||||
|
pub flags: u16,
|
||||||
|
pub sequence: u32,
|
||||||
|
pub payload_length: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PacketHeader {
|
||||||
|
pub const SIZE: usize = 12;
|
||||||
|
|
||||||
|
pub fn new(packet_id: PacketId, sequence: u32, payload_length: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
packet_id: packet_id as u16,
|
||||||
|
flags: 0,
|
||||||
|
sequence,
|
||||||
|
payload_length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_bytes(&self) -> [u8; Self::SIZE] {
|
||||||
|
let mut buf = [0u8; Self::SIZE];
|
||||||
|
buf[0..2].copy_from_slice(&self.packet_id.to_be_bytes());
|
||||||
|
buf[2..4].copy_from_slice(&self.flags.to_be_bytes());
|
||||||
|
buf[4..8].copy_from_slice(&self.sequence.to_be_bytes());
|
||||||
|
buf[8..12].copy_from_slice(&self.payload_length.to_be_bytes());
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_bytes(buf: &[u8; Self::SIZE]) -> Self {
|
||||||
|
Self {
|
||||||
|
packet_id: u16::from_be_bytes([buf[0], buf[1]]),
|
||||||
|
flags: u16::from_be_bytes([buf[2], buf[3]]),
|
||||||
|
sequence: u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
|
||||||
|
payload_length: u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
gateway/src/proto/payloads/channel.rs
Normal file
157
gateway/src/proto/payloads/channel.rs
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct HelloPayload {
|
||||||
|
pub lnex_version: String,
|
||||||
|
pub server_pubkey: String,
|
||||||
|
pub challenge_nonce: String,
|
||||||
|
pub node_name: String,
|
||||||
|
pub server_eph_pubkey: String,
|
||||||
|
pub private_mode: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct AuthPayload {
|
||||||
|
pub client_pubkey: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub lnex_version: String,
|
||||||
|
pub signature: String,
|
||||||
|
pub client_eph_pubkey: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct SessionPayload {
|
||||||
|
pub session_id: String,
|
||||||
|
pub token: String,
|
||||||
|
pub expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PingPayload {
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PongPayload {
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct JoinChannelPayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct LeaveChannelPayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelStatePayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub channel_name: String,
|
||||||
|
pub kind: String,
|
||||||
|
pub members: Vec<MemberInfo>,
|
||||||
|
pub voice_endpoint: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelCreatePayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub channel_name: String,
|
||||||
|
/// "text" or "voice".
|
||||||
|
pub kind: String,
|
||||||
|
/// Optional guild_id this channel belongs to (Phase 1.x — unused, future).
|
||||||
|
#[serde(default)]
|
||||||
|
pub guild_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelDeletePayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ChannelListPayload {
|
||||||
|
pub channels: Vec<ChannelListItem>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ChannelListItem {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub channel_name: String,
|
||||||
|
pub kind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct MemberInfo {
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub in_voice: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserJoinPayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UserLeavePayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ChatMessagePayload {
|
||||||
|
pub message_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub sender_id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
#[serde(default)]
|
||||||
|
pub edited: bool,
|
||||||
|
/// Optional message_id this message is replying to.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reply_to: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ReactionPayload {
|
||||||
|
pub message_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub emoji: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MessageEditPayload {
|
||||||
|
pub message_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
pub content: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct MessageDeletePayload {
|
||||||
|
pub message_id: String,
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ChatHistoryPayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub messages: Vec<ChatMessagePayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ErrorPayload {
|
||||||
|
pub code: u32,
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DisconnectPayload {
|
||||||
|
pub reason: String,
|
||||||
|
}
|
||||||
161
gateway/src/proto/payloads/guild.rs
Normal file
161
gateway/src/proto/payloads/guild.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildCreatePayload {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct GuildInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub owner_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub member_count: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildListPayload {
|
||||||
|
pub guilds: Vec<GuildInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildMemberJoinPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildMemberLeavePayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildMemberKickPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RoleCreatePayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: Option<String>,
|
||||||
|
pub permissions: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RoleDeletePayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub role_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct InviteCreatePayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub max_uses: Option<i64>,
|
||||||
|
pub expires_in_seconds: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct InviteInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub guild_name: String,
|
||||||
|
pub code: String,
|
||||||
|
pub creator_id: String,
|
||||||
|
pub max_uses: Option<i64>,
|
||||||
|
pub uses: i64,
|
||||||
|
pub expires_at: Option<i64>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct InviteAcceptPayload {
|
||||||
|
pub code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct InviteDeletePayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub invite_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildAuditLogFetchPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
#[serde(default = "default_audit_limit")]
|
||||||
|
pub limit: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_audit_limit() -> i64 {
|
||||||
|
50
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct AuditLogEntryPayload {
|
||||||
|
pub id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub actor_id: String,
|
||||||
|
pub action: String,
|
||||||
|
pub target_id: Option<String>,
|
||||||
|
pub target_type: Option<String>,
|
||||||
|
pub reason: Option<String>,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildAuditLogPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub entries: Vec<AuditLogEntryPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildMemberListFetchPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct GuildMemberInfoPayload {
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub joined_at: i64,
|
||||||
|
pub role_color: String,
|
||||||
|
pub role_name: String,
|
||||||
|
/// True if this user is the guild owner.
|
||||||
|
pub is_owner: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildMemberListPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub members: Vec<GuildMemberInfoPayload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct RoleAssignPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub role_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildRoleListFetchPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct GuildRoleInfoPayload {
|
||||||
|
pub id: String,
|
||||||
|
pub guild_id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub color: String,
|
||||||
|
pub permissions: u64,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct GuildRoleListPayload {
|
||||||
|
pub guild_id: String,
|
||||||
|
pub roles: Vec<GuildRoleInfoPayload>,
|
||||||
|
}
|
||||||
7
gateway/src/proto/payloads/mod.rs
Normal file
7
gateway/src/proto/payloads/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
mod channel;
|
||||||
|
mod guild;
|
||||||
|
mod social;
|
||||||
|
|
||||||
|
pub use channel::*;
|
||||||
|
pub use guild::*;
|
||||||
|
pub use social::*;
|
||||||
132
gateway/src/proto/payloads/social.rs
Normal file
132
gateway/src/proto/payloads/social.rs
Normal file
|
|
@ -0,0 +1,132 @@
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DmStartPayload {
|
||||||
|
pub target_user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DmStartResponsePayload {
|
||||||
|
pub dm_id: String,
|
||||||
|
pub other_user_id: String,
|
||||||
|
pub other_nickname: String,
|
||||||
|
pub messages: Vec<DmMessagePayload>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub unread_count: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct DmMessagePayload {
|
||||||
|
pub dm_id: String,
|
||||||
|
pub sender_id: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct DmHistoryPayload {
|
||||||
|
pub dm_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub messages: Vec<DmMessagePayload>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub search_query: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub limit: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FriendRequestPayload {
|
||||||
|
pub to_user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FriendAcceptPayload {
|
||||||
|
pub from_user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FriendDeclinePayload {
|
||||||
|
pub from_user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FriendRemovePayload {
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct FriendInfo {
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub status: String,
|
||||||
|
pub since: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct FriendListPayload {
|
||||||
|
pub friends: Vec<FriendInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BlockUserPayload {
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct UnblockUserPayload {
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct BlockListPayload {
|
||||||
|
pub blocked: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct FriendEventPayload {
|
||||||
|
pub event: String,
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PresenceUpdatePayload {
|
||||||
|
pub status: String,
|
||||||
|
pub activity_type: Option<String>,
|
||||||
|
pub activity_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct PresenceInfo {
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub status: String,
|
||||||
|
pub activity_type: Option<String>,
|
||||||
|
pub activity_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct PresenceSyncPayload {
|
||||||
|
pub presences: Vec<PresenceInfo>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct PresenceEventPayload {
|
||||||
|
pub user_id: String,
|
||||||
|
pub nickname: String,
|
||||||
|
pub status: String,
|
||||||
|
pub activity_type: Option<String>,
|
||||||
|
pub activity_text: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct ReadReceiptPayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
pub last_read_message_id: String,
|
||||||
|
pub user_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
|
pub struct TypingStartPayload {
|
||||||
|
pub channel_id: String,
|
||||||
|
}
|
||||||
23
serverd/Cargo.toml
Normal file
23
serverd/Cargo.toml
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
[package]
|
||||||
|
name = "vnox-serverd"
|
||||||
|
description = "VNOX unified server — gateway TCP + voice UDP in one binary"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "vnox-serverd"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
vnox-gateway = { path = "../gateway" }
|
||||||
|
vnox-voice-node = { path = "../voice-node" }
|
||||||
|
|
||||||
|
tokio.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
49
serverd/src/main.rs
Normal file
49
serverd/src/main.rs
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config_path = std::env::args()
|
||||||
|
.skip_while(|a| a != "--config")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "/etc/vnox/config.toml".into());
|
||||||
|
|
||||||
|
let text = std::fs::read_to_string(&config_path)?;
|
||||||
|
let cfg: vnox_gateway::domain::config::Config =
|
||||||
|
toml::from_str(&text).map_err(|e| anyhow::anyhow!("invalid config: {e}"))?;
|
||||||
|
|
||||||
|
info!(
|
||||||
|
"VNOX Server starting — node: {}",
|
||||||
|
cfg.node.name
|
||||||
|
);
|
||||||
|
info!("Gateway TCP: {}", cfg.gateway.bind);
|
||||||
|
info!("Voice UDP: {}", cfg.voice.bind);
|
||||||
|
|
||||||
|
let node_name = cfg.node.name.clone();
|
||||||
|
let voice_bind = cfg.voice.bind.clone();
|
||||||
|
let gate_cfg = Arc::new(cfg);
|
||||||
|
|
||||||
|
let voice_handle = tokio::spawn(async move {
|
||||||
|
vnox_voice_node::runner::run_bind(&node_name, &voice_bind).await
|
||||||
|
});
|
||||||
|
|
||||||
|
let gate_handle = tokio::spawn(async move {
|
||||||
|
vnox_gateway::run(gate_cfg).await
|
||||||
|
});
|
||||||
|
|
||||||
|
tokio::select! {
|
||||||
|
r = voice_handle => {
|
||||||
|
info!("voice node exited: {:?}", r);
|
||||||
|
}
|
||||||
|
r = gate_handle => {
|
||||||
|
info!("gateway exited: {:?}", r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
26
voice-node/Cargo.toml
Normal file
26
voice-node/Cargo.toml
Normal file
|
|
@ -0,0 +1,26 @@
|
||||||
|
[package]
|
||||||
|
name = "vnox-voice-node"
|
||||||
|
description = "VNOX voice node — UDP relay, Opus, jitter buffer"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
authors.workspace = true
|
||||||
|
repository.workspace = true
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "vnox_voice_node"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "vnox-voice-node"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tokio.workspace = true
|
||||||
|
opus.workspace = true
|
||||||
|
tracing.workspace = true
|
||||||
|
tracing-subscriber.workspace = true
|
||||||
|
anyhow.workspace = true
|
||||||
|
thiserror.workspace = true
|
||||||
|
toml.workspace = true
|
||||||
|
serde.workspace = true
|
||||||
75
voice-node/src/jitter/adaptive.rs
Normal file
75
voice-node/src/jitter/adaptive.rs
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
use tracing::info;
|
||||||
|
|
||||||
|
use super::JitterBuffer;
|
||||||
|
|
||||||
|
pub const MIN_TARGET_MS: u32 = 20;
|
||||||
|
pub const MAX_TARGET_MS: u32 = 150;
|
||||||
|
pub const JITTER_WINDOW: usize = 64;
|
||||||
|
const LOSS_INCREASE_THRESH: u32 = 5;
|
||||||
|
const LOSS_DECREASE_THRESH: u32 = 1;
|
||||||
|
const STABLE_WINDOWS_BEFORE_DECREASE: u32 = 10;
|
||||||
|
pub const LOSS_WINDOW_PACKETS: u32 = 50;
|
||||||
|
const ADJUST_STEP_MS: u32 = 5;
|
||||||
|
|
||||||
|
impl JitterBuffer {
|
||||||
|
pub(super) fn observed_jitter_ms(&self) -> u32 {
|
||||||
|
if self.arrival_count < 2 {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
let mut deltas = Vec::with_capacity(self.arrival_count - 1);
|
||||||
|
for i in 0..self.arrival_count - 1 {
|
||||||
|
let idx0 = (self.arrival_idx + JITTER_WINDOW - self.arrival_count + i) % JITTER_WINDOW;
|
||||||
|
let idx1 =
|
||||||
|
(self.arrival_idx + JITTER_WINDOW - self.arrival_count + i + 1) % JITTER_WINDOW;
|
||||||
|
let delta = self.arrival_times[idx1].saturating_sub(self.arrival_times[idx0]);
|
||||||
|
deltas.push(delta);
|
||||||
|
}
|
||||||
|
if deltas.is_empty() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
deltas.sort_unstable();
|
||||||
|
let p90_idx = ((deltas.len() as f64) * 0.90).ceil() as usize - 1;
|
||||||
|
let p90_idx = p90_idx.min(deltas.len() - 1);
|
||||||
|
deltas[p90_idx] as u32
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(super) fn adapt_target(&mut self) {
|
||||||
|
let loss_pct = if self.total_expected > 0 {
|
||||||
|
(self.loss_count as f64 / self.total_expected as f64 * 100.0) as u32
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
};
|
||||||
|
let jitter_ms = self.observed_jitter_ms();
|
||||||
|
let jitter_based_target = (jitter_ms * 2).clamp(MIN_TARGET_MS, MAX_TARGET_MS);
|
||||||
|
|
||||||
|
if loss_pct >= LOSS_INCREASE_THRESH {
|
||||||
|
let new_target = (self.target_ms + ADJUST_STEP_MS).min(MAX_TARGET_MS);
|
||||||
|
if new_target != self.target_ms {
|
||||||
|
info!(
|
||||||
|
target_ms = new_target,
|
||||||
|
loss_pct, jitter_ms, "adaptive: increasing target due to packet loss"
|
||||||
|
);
|
||||||
|
self.target_ms = new_target;
|
||||||
|
}
|
||||||
|
self.stable_windows = 0;
|
||||||
|
} else if loss_pct <= LOSS_DECREASE_THRESH {
|
||||||
|
self.stable_windows += 1;
|
||||||
|
if self.stable_windows >= STABLE_WINDOWS_BEFORE_DECREASE {
|
||||||
|
let candidate = self.target_ms.saturating_sub(ADJUST_STEP_MS);
|
||||||
|
let new_target = candidate.max(jitter_based_target).max(MIN_TARGET_MS);
|
||||||
|
if new_target != self.target_ms {
|
||||||
|
info!(
|
||||||
|
target_ms = new_target,
|
||||||
|
loss_pct, jitter_ms, "adaptive: decreasing target, link stable"
|
||||||
|
);
|
||||||
|
self.target_ms = new_target;
|
||||||
|
}
|
||||||
|
self.stable_windows = 0;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
self.stable_windows = 0;
|
||||||
|
}
|
||||||
|
self.loss_count = 0;
|
||||||
|
self.total_expected = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
100
voice-node/src/jitter/mod.rs
Normal file
100
voice-node/src/jitter/mod.rs
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
use std::collections::BTreeMap;
|
||||||
|
|
||||||
|
pub mod adaptive;
|
||||||
|
pub mod relay;
|
||||||
|
|
||||||
|
pub use adaptive::{JITTER_WINDOW, LOSS_WINDOW_PACKETS, MAX_TARGET_MS, MIN_TARGET_MS};
|
||||||
|
|
||||||
|
pub struct JitterBuffer {
|
||||||
|
target_ms: u32,
|
||||||
|
adaptive: bool,
|
||||||
|
packets: BTreeMap<u32, BufferedPacket>,
|
||||||
|
last_played: Option<u32>,
|
||||||
|
arrival_times: Vec<u64>,
|
||||||
|
arrival_idx: usize,
|
||||||
|
arrival_count: usize,
|
||||||
|
loss_count: u32,
|
||||||
|
total_expected: u32,
|
||||||
|
stable_windows: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl JitterBuffer {
|
||||||
|
pub fn new(target_ms: u32, adaptive: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
target_ms,
|
||||||
|
adaptive,
|
||||||
|
packets: BTreeMap::new(),
|
||||||
|
last_played: None,
|
||||||
|
arrival_times: vec![0u64; JITTER_WINDOW],
|
||||||
|
arrival_idx: 0,
|
||||||
|
arrival_count: 0,
|
||||||
|
loss_count: 0,
|
||||||
|
total_expected: 0,
|
||||||
|
stable_windows: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn push(&mut self, pkt: BufferedPacket) {
|
||||||
|
let arrived = pkt.arrived_at;
|
||||||
|
self.arrival_times[self.arrival_idx] = arrived;
|
||||||
|
self.arrival_idx = (self.arrival_idx + 1) % JITTER_WINDOW;
|
||||||
|
if self.arrival_count < JITTER_WINDOW {
|
||||||
|
self.arrival_count += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
let gap = self
|
||||||
|
.packets
|
||||||
|
.keys()
|
||||||
|
.next_back()
|
||||||
|
.map(|&highest| {
|
||||||
|
if pkt.voice_seq > highest {
|
||||||
|
pkt.voice_seq.wrapping_sub(highest).saturating_sub(1)
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
if gap > 0 {
|
||||||
|
self.loss_count += gap;
|
||||||
|
}
|
||||||
|
self.total_expected += 1 + gap;
|
||||||
|
self.packets.insert(pkt.voice_seq, pkt);
|
||||||
|
|
||||||
|
if self.adaptive && self.total_expected >= LOSS_WINDOW_PACKETS {
|
||||||
|
self.adapt_target();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn len(&self) -> usize {
|
||||||
|
self.packets.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
self.packets.is_empty()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_target_ms(&mut self, target_ms: u32) {
|
||||||
|
self.target_ms = target_ms.clamp(MIN_TARGET_MS, MAX_TARGET_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn target_ms(&self) -> u32 {
|
||||||
|
self.target_ms
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_adaptive(&self) -> bool {
|
||||||
|
self.adaptive
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BufferedPacket {
|
||||||
|
pub voice_seq: u32,
|
||||||
|
pub timestamp: u32,
|
||||||
|
pub channel_id: u64,
|
||||||
|
pub opus_data: Vec<u8>,
|
||||||
|
pub arrived_at: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests;
|
||||||
44
voice-node/src/jitter/relay.rs
Normal file
44
voice-node/src/jitter/relay.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
use super::{BufferedPacket, JitterBuffer};
|
||||||
|
|
||||||
|
impl JitterBuffer {
|
||||||
|
pub fn pop_ready(&mut self, now_ms: u64) -> Option<BufferedPacket> {
|
||||||
|
if self.adaptive {
|
||||||
|
return self.pop_ready_adaptive(now_ms);
|
||||||
|
}
|
||||||
|
self.pop_ready_fixed(now_ms)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_gap(&self) -> bool {
|
||||||
|
match self.last_played {
|
||||||
|
None => false,
|
||||||
|
Some(last) => {
|
||||||
|
let expected = last.wrapping_add(1);
|
||||||
|
!self.packets.contains_key(&expected) && !self.packets.is_empty()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_ready_fixed(&mut self, now_ms: u64) -> Option<BufferedPacket> {
|
||||||
|
let seq = *self.packets.keys().next()?;
|
||||||
|
let pkt = self.packets.get(&seq)?;
|
||||||
|
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
|
||||||
|
if buffered_for >= self.target_ms as u64 {
|
||||||
|
let pkt = self.packets.remove(&seq)?;
|
||||||
|
self.last_played = Some(seq);
|
||||||
|
return Some(pkt);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pop_ready_adaptive(&mut self, now_ms: u64) -> Option<BufferedPacket> {
|
||||||
|
let seq = *self.packets.keys().next()?;
|
||||||
|
let pkt = self.packets.get(&seq)?;
|
||||||
|
let buffered_for = now_ms.saturating_sub(pkt.arrived_at);
|
||||||
|
if buffered_for >= self.target_ms as u64 {
|
||||||
|
let pkt = self.packets.remove(&seq)?;
|
||||||
|
self.last_played = Some(seq);
|
||||||
|
return Some(pkt);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
103
voice-node/src/jitter/tests.rs
Normal file
103
voice-node/src/jitter/tests.rs
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn pkt(seq: u32, arrived_at: u64) -> BufferedPacket {
|
||||||
|
BufferedPacket {
|
||||||
|
voice_seq: seq,
|
||||||
|
timestamp: 0,
|
||||||
|
channel_id: 1,
|
||||||
|
opus_data: vec![seq as u8],
|
||||||
|
arrived_at,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pop_ready_waits_for_target_ms() {
|
||||||
|
let mut jb = JitterBuffer::new(20, false);
|
||||||
|
jb.push(pkt(1, 100));
|
||||||
|
assert!(jb.pop_ready(110).is_none());
|
||||||
|
assert_eq!(jb.pop_ready(120).unwrap().voice_seq, 1);
|
||||||
|
assert!(jb.pop_ready(120).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pop_ready_releases_in_sequence_order() {
|
||||||
|
let mut jb = JitterBuffer::new(0, false);
|
||||||
|
jb.push(pkt(2, 0));
|
||||||
|
jb.push(pkt(1, 0));
|
||||||
|
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 1);
|
||||||
|
assert_eq!(jb.pop_ready(0).unwrap().voice_seq, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn has_gap_after_played_seq() {
|
||||||
|
let mut jb = JitterBuffer::new(0, false);
|
||||||
|
jb.push(pkt(1, 0));
|
||||||
|
jb.pop_ready(0);
|
||||||
|
jb.push(pkt(3, 0));
|
||||||
|
assert!(jb.has_gap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_gap_when_next_seq_present() {
|
||||||
|
let mut jb = JitterBuffer::new(0, false);
|
||||||
|
jb.push(pkt(1, 0));
|
||||||
|
jb.pop_ready(0);
|
||||||
|
jb.push(pkt(2, 0));
|
||||||
|
assert!(!jb.has_gap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adaptive_mode_increases_on_loss() {
|
||||||
|
let mut jb = JitterBuffer::new(40, true);
|
||||||
|
jb.push(pkt(1, 0));
|
||||||
|
jb.push(pkt(3, 1));
|
||||||
|
for i in 4..=50 {
|
||||||
|
jb.push(pkt(i, i as u64));
|
||||||
|
}
|
||||||
|
assert!(jb.target_ms <= MAX_TARGET_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn set_target_ms_clamps() {
|
||||||
|
let mut jb = JitterBuffer::new(40, false);
|
||||||
|
jb.set_target_ms(0);
|
||||||
|
assert_eq!(jb.target_ms, MIN_TARGET_MS);
|
||||||
|
jb.set_target_ms(500);
|
||||||
|
assert_eq!(jb.target_ms, MAX_TARGET_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adaptive_mode_respects_flag() {
|
||||||
|
let mut jb = JitterBuffer::new(40, false);
|
||||||
|
jb.push(pkt(1, 0));
|
||||||
|
for i in 2..=100 {
|
||||||
|
jb.push(pkt(i, i as u64));
|
||||||
|
}
|
||||||
|
assert_eq!(jb.target_ms, 40);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observed_jitter_smooth_timeline() {
|
||||||
|
let mut jb = JitterBuffer::new(40, true);
|
||||||
|
for i in 0..JITTER_WINDOW {
|
||||||
|
jb.push(pkt(i as u32, (i as u64) * 10));
|
||||||
|
}
|
||||||
|
let jitter = jb.observed_jitter_ms();
|
||||||
|
assert!(
|
||||||
|
(8..=12).contains(&jitter),
|
||||||
|
"jitter = {jitter} (expected ~10)"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn observed_jitter_spike() {
|
||||||
|
let mut jb = JitterBuffer::new(40, true);
|
||||||
|
let times = [
|
||||||
|
0, 10, 20, 70, 80, 90, 140, 150, 160, 210, 220, 230, 280, 290, 300,
|
||||||
|
];
|
||||||
|
for (i, &t) in times.iter().enumerate() {
|
||||||
|
jb.push(pkt(i as u32, t));
|
||||||
|
}
|
||||||
|
let jitter = jb.observed_jitter_ms();
|
||||||
|
assert!(jitter >= 40, "jitter = {jitter} (expected >= 40)");
|
||||||
|
}
|
||||||
67
voice-node/src/lib.rs
Normal file
67
voice-node/src/lib.rs
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
pub mod jitter;
|
||||||
|
pub mod relay;
|
||||||
|
pub mod runner;
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct Config {
|
||||||
|
pub node: NodeConfig,
|
||||||
|
pub voice: VoiceConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct NodeConfig {
|
||||||
|
pub name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct VoiceConfig {
|
||||||
|
pub bind: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub use runner::run_bind;
|
||||||
|
|
||||||
|
pub fn load_config() -> Result<Config> {
|
||||||
|
let path = std::env::args()
|
||||||
|
.skip_while(|a| a != "--config")
|
||||||
|
.nth(1)
|
||||||
|
.unwrap_or_else(|| "/etc/vnox/config.toml".into());
|
||||||
|
|
||||||
|
let text = std::fs::read_to_string(&path)
|
||||||
|
.map_err(|e| anyhow::anyhow!("cannot read config {path}: {e}"))?;
|
||||||
|
|
||||||
|
toml::from_str(&text).map_err(|e| anyhow::anyhow!("invalid config: {e}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voice packet header ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
pub const VOICE_HDR_SIZE: usize = 20;
|
||||||
|
pub const VOICE_PACKET_ID: u16 = 0x0010;
|
||||||
|
pub const MAX_UDP_PACKET: usize = 1472;
|
||||||
|
pub const PLAYOUT_INTERVAL_MS: u64 = 5;
|
||||||
|
|
||||||
|
pub struct VoiceHeader {
|
||||||
|
pub packet_id: u16,
|
||||||
|
pub _flags: u16,
|
||||||
|
pub voice_seq: u32,
|
||||||
|
pub timestamp: u32,
|
||||||
|
pub channel_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VoiceHeader {
|
||||||
|
pub fn parse(buf: &[u8]) -> Option<Self> {
|
||||||
|
if buf.len() < VOICE_HDR_SIZE {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(Self {
|
||||||
|
packet_id: u16::from_be_bytes([buf[0], buf[1]]),
|
||||||
|
_flags: u16::from_be_bytes([buf[2], buf[3]]),
|
||||||
|
voice_seq: u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]),
|
||||||
|
timestamp: u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]),
|
||||||
|
channel_id: u64::from_be_bytes([
|
||||||
|
buf[12], buf[13], buf[14], buf[15], buf[16], buf[17], buf[18], buf[19],
|
||||||
|
]),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
12
voice-node/src/main.rs
Normal file
12
voice-node/src/main.rs
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
use anyhow::Result;
|
||||||
|
use vnox_voice_node::{load_config, runner};
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::fmt()
|
||||||
|
.with_env_filter(std::env::var("VNOX_LOG").unwrap_or_else(|_| "info".into()))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let config = load_config()?;
|
||||||
|
runner::run(config).await
|
||||||
|
}
|
||||||
97
voice-node/src/relay.rs
Normal file
97
voice-node/src/relay.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
use crate::jitter::{BufferedPacket, JitterBuffer};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tokio::sync::RwLock;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
|
/// Per-channel state: members, jitter buffer, and sender tracking.
|
||||||
|
pub struct ChannelState {
|
||||||
|
/// member address → last packet time
|
||||||
|
pub members: HashMap<SocketAddr, Instant>,
|
||||||
|
/// reorders and smooths voice packets
|
||||||
|
pub jitter: JitterBuffer,
|
||||||
|
/// voice_seq → original sender address (for relay after pop)
|
||||||
|
pub senders: HashMap<u32, SocketAddr>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Maps channel_id → per-channel state.
|
||||||
|
pub type ChannelMap = Arc<RwLock<HashMap<u64, ChannelState>>>;
|
||||||
|
|
||||||
|
pub fn new_channel_map() -> ChannelMap {
|
||||||
|
Arc::new(RwLock::new(HashMap::new()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop members with no packets for longer than `max_idle`.
|
||||||
|
pub async fn cleanup_stale(channels: &ChannelMap, max_idle: Duration) {
|
||||||
|
let cutoff = Instant::now() - max_idle;
|
||||||
|
let mut lock = channels.write().await;
|
||||||
|
lock.retain(|_, state| {
|
||||||
|
state.members.retain(|_, last_seen| *last_seen >= cutoff);
|
||||||
|
!state.members.is_empty()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push a raw voice packet into the jitter buffer for `channel_id`.
|
||||||
|
pub async fn push_packet(
|
||||||
|
channels: &ChannelMap,
|
||||||
|
channel_id: u64,
|
||||||
|
sender: SocketAddr,
|
||||||
|
voice_seq: u32,
|
||||||
|
timestamp: u32,
|
||||||
|
raw_data: &[u8],
|
||||||
|
arrived_at: u64,
|
||||||
|
) {
|
||||||
|
let pkt = BufferedPacket {
|
||||||
|
voice_seq,
|
||||||
|
timestamp,
|
||||||
|
channel_id,
|
||||||
|
opus_data: raw_data.to_vec(),
|
||||||
|
arrived_at,
|
||||||
|
};
|
||||||
|
let mut lock = channels.write().await;
|
||||||
|
let state = lock.entry(channel_id).or_insert_with(|| ChannelState {
|
||||||
|
members: HashMap::new(),
|
||||||
|
jitter: JitterBuffer::new(40, true),
|
||||||
|
senders: HashMap::new(),
|
||||||
|
});
|
||||||
|
state.jitter.push(pkt);
|
||||||
|
state.senders.insert(voice_seq, sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pop ready packets from every channel's jitter buffer and relay them.
|
||||||
|
pub async fn pop_and_relay(socket: &UdpSocket, channels: &ChannelMap, now_ms: u64) {
|
||||||
|
let mut lock = channels.write().await;
|
||||||
|
for (_channel_id, state) in lock.iter_mut() {
|
||||||
|
while let Some(pkt) = state.jitter.pop_ready(now_ms) {
|
||||||
|
let sender = state.senders.remove(&pkt.voice_seq).unwrap_or_else(|| {
|
||||||
|
tracing::warn!("sender not found for seq={}", pkt.voice_seq);
|
||||||
|
SocketAddr::from(([0, 0, 0, 0], 0))
|
||||||
|
});
|
||||||
|
for &addr in state.members.keys() {
|
||||||
|
if addr != sender
|
||||||
|
&& let Err(e) = socket.send_to(&pkt.opus_data, addr).await
|
||||||
|
{
|
||||||
|
debug!("relay send error to {addr}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Register (or refresh) a sender in the channel member set.
|
||||||
|
pub async fn touch_member(channels: &ChannelMap, channel_id: u64, addr: SocketAddr) {
|
||||||
|
channels
|
||||||
|
.write()
|
||||||
|
.await
|
||||||
|
.entry(channel_id)
|
||||||
|
.or_insert_with(|| ChannelState {
|
||||||
|
members: HashMap::new(),
|
||||||
|
jitter: JitterBuffer::new(40, true),
|
||||||
|
senders: HashMap::new(),
|
||||||
|
})
|
||||||
|
.members
|
||||||
|
.insert(addr, Instant::now());
|
||||||
|
}
|
||||||
97
voice-node/src/runner.rs
Normal file
97
voice-node/src/runner.rs
Normal file
|
|
@ -0,0 +1,97 @@
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use tokio::net::UdpSocket;
|
||||||
|
use tracing::{debug, error, info, warn};
|
||||||
|
|
||||||
|
use crate::relay;
|
||||||
|
use crate::{
|
||||||
|
Config, MAX_UDP_PACKET, PLAYOUT_INTERVAL_MS, VOICE_HDR_SIZE, VOICE_PACKET_ID, VoiceHeader,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn run(config: Config) -> anyhow::Result<()> {
|
||||||
|
run_bind(&config.node.name, &config.voice.bind).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn run_bind(node_name: &str, bind: &str) -> anyhow::Result<()> {
|
||||||
|
info!("VNOX Voice Node starting — node: {node_name}");
|
||||||
|
info!("UDP bind: {bind}");
|
||||||
|
|
||||||
|
let socket = UdpSocket::bind(bind).await?;
|
||||||
|
info!("voice node listening on {bind}");
|
||||||
|
|
||||||
|
let channels = relay::new_channel_map();
|
||||||
|
|
||||||
|
let channels_cleanup = channels.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_secs(15));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
relay::cleanup_stale(&channels_cleanup, Duration::from_secs(30)).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let socket = Arc::new(socket);
|
||||||
|
let socket_relay = socket.clone();
|
||||||
|
let channels_playout = channels.clone();
|
||||||
|
let epoch = Instant::now();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(Duration::from_millis(PLAYOUT_INTERVAL_MS));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
let now_ms = epoch.elapsed().as_millis() as u64;
|
||||||
|
relay::pop_and_relay(socket_relay.as_ref(), &channels_playout, now_ms).await;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut buf = vec![0u8; MAX_UDP_PACKET];
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (len, src) = match socket.recv_from(&mut buf).await {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(e) => {
|
||||||
|
error!("UDP recv error: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let data = &buf[..len];
|
||||||
|
|
||||||
|
let hdr = match VoiceHeader::parse(data) {
|
||||||
|
Some(h) => h,
|
||||||
|
None => {
|
||||||
|
warn!("short packet from {src} ({len} bytes), dropping");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if hdr.packet_id != VOICE_PACKET_ID {
|
||||||
|
debug!(
|
||||||
|
"non-voice packet 0x{:04X} from {src}, dropping",
|
||||||
|
hdr.packet_id
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
relay::touch_member(&channels, hdr.channel_id, src).await;
|
||||||
|
|
||||||
|
let arrived_at = epoch.elapsed().as_millis() as u64;
|
||||||
|
|
||||||
|
debug!(
|
||||||
|
"voice seq={} ch={} from {src} ({} bytes opus)",
|
||||||
|
hdr.voice_seq,
|
||||||
|
hdr.channel_id,
|
||||||
|
len - VOICE_HDR_SIZE,
|
||||||
|
);
|
||||||
|
|
||||||
|
relay::push_packet(
|
||||||
|
&channels,
|
||||||
|
hdr.channel_id,
|
||||||
|
src,
|
||||||
|
hdr.voice_seq,
|
||||||
|
hdr.timestamp,
|
||||||
|
data,
|
||||||
|
arrived_at,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue