Compare commits
10
Commits
57e4da2ba4
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0cb8b068a8 | ||
|
|
910eb2b169 | ||
|
|
844eaadee0 | ||
|
|
5bac3174df | ||
|
|
b830abe3f4 | ||
|
|
d1ce0655fb | ||
|
|
b234359c4a | ||
|
|
ab97dcc8d9 | ||
|
|
3780092fa6 | ||
|
|
ff71166e28 |
+2
-1
@@ -1,4 +1,5 @@
|
||||
/target
|
||||
/.idea
|
||||
*.db*
|
||||
/media/*
|
||||
/media/*
|
||||
/certs
|
||||
Generated
+133
-3
@@ -189,6 +189,15 @@ dependencies = [
|
||||
"derive_arbitrary",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
version = "1.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c049c0be4daef0b145cb3555416b3b8ef5b7888a38aea1a3a155801fe7b0810b"
|
||||
dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
@@ -704,6 +713,28 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "axum-server"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1ab4a3ec9ea8a657c72d99a03a824af695bd0fb5ec639ccbd9cd3543b41a5f9"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"bytes",
|
||||
"fs-err",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"hyper 1.11.0",
|
||||
"hyper-util",
|
||||
"pin-project-lite",
|
||||
"rustls",
|
||||
"rustls-pemfile",
|
||||
"rustls-pki-types",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tower-service",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "base16ct"
|
||||
version = "0.2.0"
|
||||
@@ -1753,6 +1784,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs-err"
|
||||
version = "3.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b91aa448ca50d7e79433bdf3ee8d99215430d2ec02ade5aefab2a073a1822e8a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fs_extra"
|
||||
version = "1.3.0"
|
||||
@@ -1992,6 +2033,25 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "h2"
|
||||
version = "0.4.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef8e5e5a340588f4452631496976cf8636d4a7ecf600239fdc27615d2530bc16"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
"fnv",
|
||||
"futures-core",
|
||||
"futures-sink",
|
||||
"http 1.5.0",
|
||||
"indexmap",
|
||||
"slab",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "half"
|
||||
version = "2.7.1"
|
||||
@@ -2191,7 +2251,7 @@ dependencies = [
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-util",
|
||||
"h2",
|
||||
"h2 0.3.27",
|
||||
"http 0.2.12",
|
||||
"http-body 0.4.6",
|
||||
"httparse",
|
||||
@@ -2215,6 +2275,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"h2 0.4.19",
|
||||
"http 1.5.0",
|
||||
"http-body 1.1.0",
|
||||
"httparse",
|
||||
@@ -2957,6 +3018,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"axum-extra",
|
||||
"axum-server",
|
||||
"bitflags",
|
||||
"chrono",
|
||||
"config",
|
||||
@@ -2967,6 +3029,7 @@ dependencies = [
|
||||
"log",
|
||||
"migration",
|
||||
"parking_lot",
|
||||
"rcgen 0.13.2",
|
||||
"rustrtc",
|
||||
"sea-orm",
|
||||
"serde",
|
||||
@@ -3482,6 +3545,19 @@ dependencies = [
|
||||
"crossbeam-utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.13.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75e669e5202259b5314d1ea5397316ad400819437857b90861765f24c4cf80a2"
|
||||
dependencies = [
|
||||
"pem 3.0.6",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"yasna 0.5.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rcgen"
|
||||
version = "0.14.10"
|
||||
@@ -3493,7 +3569,7 @@ dependencies = [
|
||||
"rustls-pki-types",
|
||||
"time",
|
||||
"x509-parser",
|
||||
"yasna",
|
||||
"yasna 0.6.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3703,6 +3779,29 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
"rustls-webpki",
|
||||
"subtle",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pemfile"
|
||||
version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-pki-types"
|
||||
version = "1.15.1"
|
||||
@@ -3712,6 +3811,18 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
"untrusted 0.9.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustrtc"
|
||||
version = "0.3.138"
|
||||
@@ -3735,7 +3846,7 @@ dependencies = [
|
||||
"p256",
|
||||
"parking_lot",
|
||||
"rand 0.10.2",
|
||||
"rcgen",
|
||||
"rcgen 0.14.10",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha1 0.11.0",
|
||||
@@ -4635,6 +4746,16 @@ dependencies = [
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0c85f2c3ef0b1cd58b36682f4b17aaa995f0e5db534d85692b4903abce21f67"
|
||||
dependencies = [
|
||||
"rustls",
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-stream"
|
||||
version = "0.1.19"
|
||||
@@ -5446,6 +5567,15 @@ version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
|
||||
dependencies = [
|
||||
"time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "yasna"
|
||||
version = "0.6.0"
|
||||
|
||||
@@ -43,3 +43,5 @@ form_urlencoded = "1.2.2"
|
||||
time = "0.3.55"
|
||||
sha2 = "0.11.0"
|
||||
rustrtc = "0.3.138"
|
||||
axum-server = { version = "0.7", features = ["tls-rustls"] }
|
||||
rcgen = "0.13"
|
||||
|
||||
+10
@@ -7,6 +7,16 @@ host = "0.0.0.0"
|
||||
tcp_port = 8080
|
||||
# Voice/Video port
|
||||
udp_port = 8080
|
||||
# If the server is behind NAT, set its public IPv4 here and forward UDP 8080 to it.
|
||||
# external_ip = "203.0.113.1"
|
||||
# STUN is enabled by default; customize or set [] to disable it.
|
||||
# stun_servers = ["stun:stun.l.google.com:19302"]
|
||||
|
||||
# Uncomment for native self-signed HTTPS/WSS. Keep both files between restarts.
|
||||
[network.tls]
|
||||
cert_path = "certs/server.pem"
|
||||
key_path = "certs/server-key.pem"
|
||||
names = ["localhost", "127.0.0.1", "goesseau.eu"] # include the host clients actually use
|
||||
|
||||
[database]
|
||||
# DSN for database
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -16,8 +16,8 @@ harness = false
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
|
||||
parking_lot = "0.12.5"
|
||||
tracing = "0.1"
|
||||
uuid = { version = "1.26.1", features = ["v4"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
|
||||
criterion = { version = "0.8.2", features = ["async_tokio"] }
|
||||
uuid = { version = "1.26.1", features = ["v4"] }
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
# event_bus
|
||||
|
||||
Un bus d'événements asynchrone en mémoire pour Tokio, entièrement basé sur le typage fort en Rust (`TypeId`).
|
||||
|
||||
---
|
||||
|
||||
## 🎯 Problématique résolue
|
||||
|
||||
Dans l'implémentation initiale (`event_bus`), le modèle était inspiré de JavaScript (topics basés sur des chaînes de caractères) :
|
||||
- Les événements étaient transportés via un pointeur générique `AnyEvent` (`Arc<dyn Any + Send + Sync>`).
|
||||
- L'émission imposait de spécifier un topic string (`bus.emit("topic", event)`).
|
||||
- La réception nécessitait de spécifier à la fois le type et le topic string (`bus.on_async::<Event, _, _>("topic", ...)`), puis d'effectuer un déréférencement / downcast dynamique (`downcast_ref::<T>()` ou macro `match_event!`) sur chaque message reçu.
|
||||
|
||||
**`event_bus` résout entièrement cette complexité :**
|
||||
- **Typage fort natif** : le routage est directement effectué par l'identifiant de type (`std::any::TypeId`), sans nom de topic requis.
|
||||
- **Zéro downcast / déréférencement à la réception** : le callback reçoit directement la structure d'événement typée.
|
||||
- **Syntaxe ergonomique** : support complet de la syntaxe turbofish demandée `bus.on_async::<MessageUpdatedEvent>(|event| async move { ... })` ainsi que de l'inférence automatique `bus.on_async(|event: MessageUpdatedEvent| async move { ... })`.
|
||||
- **Source unique de vérité** : plus besoin de `Scope` externe ; le contexte (ex: `channel_id`, `server_id`, `caller_id`) est directement transporté dans les champs de la structure typée.
|
||||
- **Targeted wake-up** : canaux Tokio `broadcast` isolés par type d'événement, garantissant des performances optimales sans réveil inutile de tâches.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Utilisation
|
||||
|
||||
### 1. Définir des événements
|
||||
|
||||
N'importe quelle structure Rust implémentant `Clone + Send + Sync + 'static` est automatiquement un `Event` valide (aucun macro derive supplémentaire nécessaire) :
|
||||
|
||||
```rust
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MessageCreatedEvent {
|
||||
pub server_id: Option<Uuid>,
|
||||
pub channel_id: Uuid,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct MessageUpdatedEvent {
|
||||
pub id: u64,
|
||||
pub content: String,
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Émission d'événements
|
||||
|
||||
```rust
|
||||
use event_bus::EventBus;
|
||||
|
||||
let bus = EventBus::new();
|
||||
|
||||
// Émission typée directe
|
||||
bus.emit(MessageUpdatedEvent {
|
||||
id: 42,
|
||||
content: "Nouveau message".into(),
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Réception asynchrone (`on_async`)
|
||||
|
||||
Syntaxe turbofish exacte demandée :
|
||||
|
||||
```rust
|
||||
bus.on_async::<MessageUpdatedEvent>(|event| async move {
|
||||
// `event` est directement de type MessageUpdatedEvent
|
||||
println!("Message {} mis à jour : {}", event.id, event.content);
|
||||
});
|
||||
```
|
||||
|
||||
Ou avec inférence sur l'argument de fermeture :
|
||||
|
||||
```rust
|
||||
bus.on_async(|event: MessageUpdatedEvent| async move {
|
||||
println!("Contenu : {}", event.content);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Réception synchrone (`on`)
|
||||
|
||||
```rust
|
||||
bus.on::<MessageCreatedEvent>(|event| {
|
||||
println!("Nouveau message créé sur le salon : {:?}", event.channel_id);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Utilisation avec contexte injecté (`on_async_with`)
|
||||
|
||||
Pratique pour passer des services ou repositories sans clones manuels répétés :
|
||||
|
||||
```rust
|
||||
bus.on_async_with::<MessageCreatedEvent, _>(router, |router, event| async move {
|
||||
router.gateway.send(...);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Écouteurs One-Shot (`wait_next` et `wait_for`)
|
||||
|
||||
Permet d'attendre un événement de manière linéaire avec une `Future` (sans boucle manuelle ni fuite de souscription) :
|
||||
|
||||
```rust
|
||||
// Attend le tout prochain événement de ce type
|
||||
let event = bus.wait_next::<MessageCreatedEvent>().await?;
|
||||
|
||||
// Ou attend un événement répondant à une condition précise
|
||||
let confirmed = bus.wait_for::<MessageSavedEvent>(|e| e.id == target_id).await?;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. Flux direct / Récepteur sans callback (`subscribe`)
|
||||
|
||||
Si vous préférez consommer les événements dans votre propre boucle de streaming :
|
||||
|
||||
```rust
|
||||
let mut rx = bus.subscribe::<MessageUpdatedEvent>();
|
||||
|
||||
tokio::spawn(async move {
|
||||
while let Ok(event) = rx.recv().await {
|
||||
// `event` est directement MessageUpdatedEvent, aucun `match_event!` requis !
|
||||
println!("Reçu : {}", event.content);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Tests et Benchmarks
|
||||
|
||||
Exécuter les tests du crate :
|
||||
```bash
|
||||
cargo test --manifest-path event_bus/Cargo.toml
|
||||
```
|
||||
|
||||
Exécuter les benchmarks Criterion :
|
||||
```bash
|
||||
cargo bench --manifest-path event_bus/Cargo.toml
|
||||
```
|
||||
@@ -1,3 +1,5 @@
|
||||
#![allow(dead_code)]
|
||||
|
||||
use std::sync::{
|
||||
Arc,
|
||||
atomic::{AtomicU64, Ordering},
|
||||
@@ -8,8 +10,6 @@ use criterion::{Criterion, Throughput, criterion_group, criterion_main};
|
||||
use event_bus::EventBus;
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
const TOPIC: &str = "bench-topic";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SmallEvent {
|
||||
value: u64,
|
||||
@@ -38,57 +38,46 @@ fn runtime() -> Runtime {
|
||||
Runtime::new().expect("failed to create tokio runtime")
|
||||
}
|
||||
|
||||
fn wait_until_received(
|
||||
received: &AtomicU64,
|
||||
expected: u64,
|
||||
) -> impl std::future::Future<Output = ()> + '_ {
|
||||
async move {
|
||||
while received.load(Ordering::Relaxed) < expected {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
async fn wait_until_received(received: &AtomicU64, expected: u64) {
|
||||
while received.load(Ordering::Relaxed) < expected {
|
||||
tokio::task::yield_now().await;
|
||||
}
|
||||
}
|
||||
|
||||
fn bench_emit_no_subscriber(c: &mut Criterion) {
|
||||
let mut group = c.benchmark_group("event_bus/no_subscriber");
|
||||
let mut group = c.benchmark_group("event_bus_typed/no_subscriber");
|
||||
group.throughput(Throughput::Elements(1));
|
||||
|
||||
let bus = EventBus::with_capacity(1024);
|
||||
|
||||
group.bench_function("u64", |b| {
|
||||
b.iter(|| {
|
||||
bus.emit(TOPIC, 42_u64);
|
||||
bus.emit(42_u64);
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("small_struct", |b| {
|
||||
b.iter(|| {
|
||||
bus.emit(TOPIC, SmallEvent { value: 42 });
|
||||
bus.emit(SmallEvent { value: 42 });
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("string_struct", |b| {
|
||||
b.iter(|| {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
StringEvent {
|
||||
id: 42,
|
||||
name: "Alice".to_string(),
|
||||
message: "hello from benchmark".to_string(),
|
||||
},
|
||||
);
|
||||
bus.emit(StringEvent {
|
||||
id: 42,
|
||||
name: "Alice".to_string(),
|
||||
message: "hello from benchmark".to_string(),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("vec_payload_1kb", |b| {
|
||||
b.iter(|| {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
VecEvent {
|
||||
id: 42,
|
||||
payload: vec![7_u8; 1024],
|
||||
},
|
||||
);
|
||||
bus.emit(VecEvent {
|
||||
id: 42,
|
||||
payload: vec![7_u8; 1024],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,13 +85,10 @@ fn bench_emit_no_subscriber(c: &mut Criterion) {
|
||||
|
||||
group.bench_function("arc_payload_1kb", |b| {
|
||||
b.iter(|| {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
ArcPayloadEvent {
|
||||
id: 42,
|
||||
payload: Arc::clone(&shared_payload),
|
||||
},
|
||||
);
|
||||
bus.emit(ArcPayloadEvent {
|
||||
id: 42,
|
||||
payload: Arc::clone(&shared_payload),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,13 +98,13 @@ fn bench_emit_no_subscriber(c: &mut Criterion) {
|
||||
fn bench_raw_subscriber(c: &mut Criterion) {
|
||||
let rt = runtime();
|
||||
|
||||
let mut group = c.benchmark_group("event_bus/raw_subscriber");
|
||||
let mut group = c.benchmark_group("event_bus_typed/raw_subscriber");
|
||||
group.throughput(Throughput::Elements(1));
|
||||
|
||||
group.bench_function("u64", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let mut rx = bus.on_raw(TOPIC);
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
let mut rx = bus.subscribe::<u64>();
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let receiver_count = Arc::clone(&received);
|
||||
@@ -134,23 +120,21 @@ fn bench_raw_subscriber(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(TOPIC, i);
|
||||
bus.emit(i);
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
receiver.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("small_struct", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let mut rx = bus.on_raw(TOPIC);
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
let mut rx = bus.subscribe::<SmallEvent>();
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let receiver_count = Arc::clone(&received);
|
||||
@@ -166,23 +150,21 @@ fn bench_raw_subscriber(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(TOPIC, SmallEvent { value: i });
|
||||
bus.emit(SmallEvent { value: i });
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
receiver.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("string_struct", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let mut rx = bus.on_raw(TOPIC);
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
let mut rx = bus.subscribe::<StringEvent>();
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let receiver_count = Arc::clone(&received);
|
||||
@@ -198,30 +180,25 @@ fn bench_raw_subscriber(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
StringEvent {
|
||||
id: i,
|
||||
name: "Alice".to_string(),
|
||||
message: "hello from benchmark".to_string(),
|
||||
},
|
||||
);
|
||||
bus.emit(StringEvent {
|
||||
id: i,
|
||||
name: "Alice".to_string(),
|
||||
message: "hello from benchmark".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
receiver.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("vec_payload_1kb", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let mut rx = bus.on_raw(TOPIC);
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
let mut rx = bus.subscribe::<VecEvent>();
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let receiver_count = Arc::clone(&received);
|
||||
@@ -237,62 +214,56 @@ fn bench_raw_subscriber(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
VecEvent {
|
||||
id: i,
|
||||
payload: vec![7_u8; 1024],
|
||||
},
|
||||
);
|
||||
bus.emit(VecEvent {
|
||||
id: i,
|
||||
payload: vec![7_u8; 1024],
|
||||
});
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
receiver.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
let shared_payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice());
|
||||
|
||||
group.bench_function("arc_payload_1kb", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let mut rx = bus.on_raw(TOPIC);
|
||||
let shared = Arc::clone(&shared_payload);
|
||||
b.to_async(&rt).iter_custom(|iters| {
|
||||
let payload = Arc::clone(&shared);
|
||||
async move {
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
let mut rx = bus.subscribe::<ArcPayloadEvent>();
|
||||
|
||||
let payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice());
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let receiver_count = Arc::clone(&received);
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let receiver_count = Arc::clone(&received);
|
||||
|
||||
let receiver = tokio::spawn(async move {
|
||||
while receiver_count.load(Ordering::Relaxed) < iters {
|
||||
if rx.recv().await.is_ok() {
|
||||
receiver_count.fetch_add(1, Ordering::Relaxed);
|
||||
let receiver = tokio::spawn(async move {
|
||||
while receiver_count.load(Ordering::Relaxed) < iters {
|
||||
if rx.recv().await.is_ok() {
|
||||
receiver_count.fetch_add(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let start = Instant::now();
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
ArcPayloadEvent {
|
||||
for i in 0..iters {
|
||||
bus.emit(ArcPayloadEvent {
|
||||
id: i,
|
||||
payload: Arc::clone(&payload),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
receiver.abort();
|
||||
elapsed
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
receiver.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
@@ -302,17 +273,17 @@ fn bench_raw_subscriber(c: &mut Criterion) {
|
||||
fn bench_typed_callback(c: &mut Criterion) {
|
||||
let rt = runtime();
|
||||
|
||||
let mut group = c.benchmark_group("event_bus/typed_callback");
|
||||
let mut group = c.benchmark_group("event_bus_typed/typed_callback");
|
||||
group.throughput(Throughput::Elements(1));
|
||||
|
||||
group.bench_function("u64", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let handler_count = Arc::clone(&received);
|
||||
|
||||
let subscription = bus.on::<u64, _>(TOPIC, move |event| {
|
||||
let subscription = bus.on::<u64>(move |event| {
|
||||
let _ = event;
|
||||
handler_count.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
@@ -320,27 +291,25 @@ fn bench_typed_callback(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(TOPIC, i);
|
||||
bus.emit(i);
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
subscription.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("small_struct", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let handler_count = Arc::clone(&received);
|
||||
|
||||
let subscription = bus.on::<SmallEvent, _>(TOPIC, move |event| {
|
||||
let subscription = bus.on::<SmallEvent>(move |event| {
|
||||
let _ = event.value;
|
||||
handler_count.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
@@ -348,27 +317,25 @@ fn bench_typed_callback(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(TOPIC, SmallEvent { value: i });
|
||||
bus.emit(SmallEvent { value: i });
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
subscription.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("string_struct", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let handler_count = Arc::clone(&received);
|
||||
|
||||
let subscription = bus.on::<StringEvent, _>(TOPIC, move |event| {
|
||||
let subscription = bus.on::<StringEvent>(move |event| {
|
||||
let _ = event.id;
|
||||
let _ = event.name.len();
|
||||
let _ = event.message.len();
|
||||
@@ -378,34 +345,29 @@ fn bench_typed_callback(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
StringEvent {
|
||||
id: i,
|
||||
name: "Alice".to_string(),
|
||||
message: "hello from benchmark".to_string(),
|
||||
},
|
||||
);
|
||||
bus.emit(StringEvent {
|
||||
id: i,
|
||||
name: "Alice".to_string(),
|
||||
message: "hello from benchmark".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
subscription.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
group.bench_function("vec_payload_1kb", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let handler_count = Arc::clone(&received);
|
||||
|
||||
let subscription = bus.on::<VecEvent, _>(TOPIC, move |event| {
|
||||
let subscription = bus.on::<VecEvent>(move |event| {
|
||||
let _ = event.id;
|
||||
let _ = event.payload.len();
|
||||
handler_count.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -414,58 +376,53 @@ fn bench_typed_callback(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
VecEvent {
|
||||
id: i,
|
||||
payload: vec![7_u8; 1024],
|
||||
},
|
||||
);
|
||||
bus.emit(VecEvent {
|
||||
id: i,
|
||||
payload: vec![7_u8; 1024],
|
||||
});
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
subscription.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
let shared_payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice());
|
||||
|
||||
group.bench_function("arc_payload_1kb", |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice());
|
||||
let shared = Arc::clone(&shared_payload);
|
||||
b.to_async(&rt).iter_custom(|iters| {
|
||||
let payload = Arc::clone(&shared);
|
||||
async move {
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let handler_count = Arc::clone(&received);
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
let handler_count = Arc::clone(&received);
|
||||
|
||||
let subscription = bus.on::<ArcPayloadEvent, _>(TOPIC, move |event| {
|
||||
let _ = event.id;
|
||||
let _ = event.payload.len();
|
||||
handler_count.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
let subscription = bus.on::<ArcPayloadEvent>(move |event| {
|
||||
let _ = event.id;
|
||||
let _ = event.payload.len();
|
||||
handler_count.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
|
||||
let start = Instant::now();
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(
|
||||
TOPIC,
|
||||
ArcPayloadEvent {
|
||||
for i in 0..iters {
|
||||
bus.emit(ArcPayloadEvent {
|
||||
id: i,
|
||||
payload: Arc::clone(&payload),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
subscription.abort();
|
||||
elapsed
|
||||
}
|
||||
|
||||
wait_until_received(&received, iters).await;
|
||||
|
||||
let elapsed = start.elapsed();
|
||||
|
||||
subscription.abort();
|
||||
|
||||
elapsed
|
||||
});
|
||||
});
|
||||
|
||||
@@ -475,13 +432,13 @@ fn bench_typed_callback(c: &mut Criterion) {
|
||||
fn bench_multiple_subscribers(c: &mut Criterion) {
|
||||
let rt = runtime();
|
||||
|
||||
let mut group = c.benchmark_group("event_bus/multiple_subscribers");
|
||||
let mut group = c.benchmark_group("event_bus_typed/multiple_subscribers");
|
||||
group.throughput(Throughput::Elements(1));
|
||||
|
||||
for subscriber_count in [1_u64, 2, 4, 8, 16, 32] {
|
||||
group.bench_function(format!("{subscriber_count}_subscribers"), |b| {
|
||||
b.to_async(&rt).iter_custom(|iters| async move {
|
||||
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024));
|
||||
let bus = EventBus::with_capacity(iters as usize + 1024);
|
||||
|
||||
let expected = iters * subscriber_count;
|
||||
let received = Arc::new(AtomicU64::new(0));
|
||||
@@ -490,7 +447,7 @@ fn bench_multiple_subscribers(c: &mut Criterion) {
|
||||
for _ in 0..subscriber_count {
|
||||
let handler_count = Arc::clone(&received);
|
||||
|
||||
let subscription = bus.on::<SmallEvent, _>(TOPIC, move |event| {
|
||||
let subscription = bus.on::<SmallEvent>(move |event| {
|
||||
let _ = event.value;
|
||||
handler_count.fetch_add(1, Ordering::Relaxed);
|
||||
});
|
||||
@@ -501,7 +458,7 @@ fn bench_multiple_subscribers(c: &mut Criterion) {
|
||||
let start = Instant::now();
|
||||
|
||||
for i in 0..iters {
|
||||
bus.emit(TOPIC, SmallEvent { value: i });
|
||||
bus.emit(SmallEvent { value: i });
|
||||
}
|
||||
|
||||
wait_until_received(&received, expected).await;
|
||||
|
||||
+410
-401
@@ -1,420 +1,90 @@
|
||||
use std::any::Any;
|
||||
use std::future::Future;
|
||||
use std::any::{Any, TypeId};
|
||||
use std::collections::HashMap;
|
||||
use std::fmt::Debug;
|
||||
use std::sync::Arc;
|
||||
|
||||
use parking_lot::RwLock;
|
||||
use std::collections::HashMap;
|
||||
use std::iter;
|
||||
use tokio::sync::broadcast;
|
||||
use tokio::task::JoinHandle;
|
||||
// use tracing::log::kv::{Key, Value};
|
||||
use tracing::{debug, trace, warn};
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Raw event type: an atomic reference-counted pointer to any value.
|
||||
pub type AnyEvent = Arc<dyn Any + Send + Sync>;
|
||||
use crate::handler::{AsyncHandler, AsyncHandlerWith};
|
||||
|
||||
/// Default buffer capacity for each broadcast channel.
|
||||
const DEFAULT_CAPACITY: usize = 64;
|
||||
pub const DEFAULT_CAPACITY: usize = 1024;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ScopeValue {
|
||||
String(String),
|
||||
Uuid(Uuid),
|
||||
}
|
||||
impl ScopeValue {
|
||||
fn into_string(self) -> String {
|
||||
match self {
|
||||
Self::String(value) => value,
|
||||
Self::Uuid(value) => value.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
/// Marker trait for events dispatched through [`EventBus`].
|
||||
///
|
||||
/// Any type implementing `Clone + Send + Sync + 'static` automatically
|
||||
/// implements `Event`.
|
||||
pub trait Event: Clone + Send + Sync + 'static {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Scope {
|
||||
pub key: String,
|
||||
pub value: ScopeValue,
|
||||
}
|
||||
impl<T: Clone + Send + Sync + 'static> Event for T {}
|
||||
|
||||
impl Scope {
|
||||
pub fn new(key: impl Into<String>, value: ScopeValue) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
value,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uuid(key: impl Into<String>, value: Uuid) -> Self {
|
||||
Self::new(key, ScopeValue::Uuid(value))
|
||||
}
|
||||
|
||||
pub fn string(key: impl Into<String>, value: impl Into<String>) -> Self {
|
||||
Self::new(key, ScopeValue::String(value.into()))
|
||||
}
|
||||
}
|
||||
impl IntoIterator for Scope {
|
||||
type Item = Scope;
|
||||
type IntoIter = iter::Once<Scope>;
|
||||
|
||||
fn into_iter(self) -> Self::IntoIter {
|
||||
iter::once(self)
|
||||
}
|
||||
}
|
||||
|
||||
/// The central event bus.
|
||||
///
|
||||
/// Share it via `Arc<EventBus>` across modules.
|
||||
/// Each topic has its own broadcast channel: only subscribers of the matching
|
||||
/// topic are woken up on `emit` (targeted wake-up).
|
||||
///
|
||||
/// # Minimal example — sync callback
|
||||
/// ```rust,no_run
|
||||
/// use std::sync::Arc;
|
||||
/// use oxspeak_server_lib::event_bus::EventBus;
|
||||
///
|
||||
/// #[derive(Clone, Debug)]
|
||||
/// struct User { name: String }
|
||||
///
|
||||
/// # tokio_test::block_on(async {
|
||||
/// let bus = Arc::new(EventBus::new());
|
||||
///
|
||||
/// bus.on::<User>("user-connected", |user| {
|
||||
/// println!("Connected: {:?}", user);
|
||||
/// });
|
||||
///
|
||||
/// bus.emit("user-connected", User { name: "Alice".into() });
|
||||
/// # tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
/// # });
|
||||
/// ```
|
||||
///
|
||||
/// # Example — async callback
|
||||
/// ```rust,no_run
|
||||
/// use std::sync::Arc;
|
||||
/// use oxspeak_server_lib::event_bus::EventBus;
|
||||
///
|
||||
/// #[derive(Clone, Debug)]
|
||||
/// struct User { name: String }
|
||||
///
|
||||
/// # tokio_test::block_on(async {
|
||||
/// let bus = Arc::new(EventBus::new());
|
||||
///
|
||||
/// bus.on_async::<User, _, _>("user-connected", |user| async move {
|
||||
/// println!("(async) Connected: {:?}", user);
|
||||
/// });
|
||||
///
|
||||
/// bus.emit("user-connected", User { name: "Bob".into() });
|
||||
/// # tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
/// # });
|
||||
/// ```
|
||||
#[derive(Debug)]
|
||||
pub struct EventBus {
|
||||
/// Channels indexed by exact topic.
|
||||
channels: RwLock<HashMap<String, broadcast::Sender<AnyEvent>>>,
|
||||
struct EventBusInner {
|
||||
channels: RwLock<HashMap<TypeId, Box<dyn Any + Send + Sync>>>,
|
||||
capacity: usize,
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
/// Creates a bus with the default capacity (64 messages per channel).
|
||||
pub fn new() -> Self {
|
||||
debug!(
|
||||
"EventBus created with default capacity ({})",
|
||||
DEFAULT_CAPACITY
|
||||
);
|
||||
Self {
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
capacity: DEFAULT_CAPACITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a bus with a custom buffer capacity.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
debug!("EventBus created with capacity {}", capacity);
|
||||
Self {
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Internal
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_or_create_sender(&self, topic: &str) -> broadcast::Sender<AnyEvent> {
|
||||
{
|
||||
let channels = self.channels.read();
|
||||
if let Some(tx) = channels.get(topic) {
|
||||
return tx.clone();
|
||||
}
|
||||
}
|
||||
let mut channels = self.channels.write();
|
||||
let created = !channels.contains_key(topic);
|
||||
let tx = channels
|
||||
.entry(topic.to_string())
|
||||
.or_insert_with(|| {
|
||||
let (tx, _) = broadcast::channel(self.capacity);
|
||||
tx
|
||||
})
|
||||
.clone();
|
||||
if created {
|
||||
debug!(topic, "New broadcast channel created");
|
||||
}
|
||||
tx
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Emission
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Emits an event on a topic.
|
||||
///
|
||||
/// - Pushes the event into the exact-topic channel (if subscribers exist).
|
||||
/// - If nobody is listening, the event is silently dropped.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use std::sync::Arc;
|
||||
/// # use oxspeak_server_lib::event_bus::EventBus;
|
||||
/// # #[derive(Clone)] struct User;
|
||||
/// # let bus = Arc::new(EventBus::new());
|
||||
/// bus.emit("user-connected", User);
|
||||
/// bus.emit("user-deleted", uuid::Uuid::new_v4());
|
||||
/// ```
|
||||
pub fn emit<T: Any + Send + Sync + 'static>(&self, topic: &str, event: T) {
|
||||
trace!(topic, "Emitting event");
|
||||
let event: AnyEvent = Arc::new(event);
|
||||
|
||||
self.emit_arc(topic, event);
|
||||
}
|
||||
|
||||
// todo : undocumented...
|
||||
pub fn emit_scoped<T>(&self, topic: &str, scopes: impl IntoIterator<Item = Scope>, event: T)
|
||||
where
|
||||
T: Any + Send + Sync + 'static,
|
||||
{
|
||||
let event: AnyEvent = Arc::new(event);
|
||||
|
||||
// Émission sur le topic général.
|
||||
self.emit_arc(topic, Arc::clone(&event));
|
||||
|
||||
// Émission sur chaque topic scoped.
|
||||
for scope in scopes {
|
||||
let scoped_topic = format!("{}:{}:{}", scope.key, scope.value.into_string(), topic);
|
||||
|
||||
self.emit_arc(&scoped_topic, Arc::clone(&event));
|
||||
}
|
||||
}
|
||||
|
||||
// todo : undocumented...
|
||||
fn emit_arc(&self, topic: &str, event: AnyEvent) {
|
||||
trace!(topic, "Emitting event");
|
||||
|
||||
if let Some(tx) = self.channels.read().get(topic) {
|
||||
let receiver_count = tx.receiver_count();
|
||||
let _ = tx.send(event);
|
||||
|
||||
trace!(topic, receiver_count, "Event delivered to channel");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Subscription — callbacks (main API)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Subscribes to a topic and calls `handler` on each event of type `T`.
|
||||
///
|
||||
/// The handler runs in a dedicated Tokio task (fire-and-forget).
|
||||
/// Events of a different type are silently ignored.
|
||||
/// Returns a [`JoinHandle`] to cancel the subscription if needed.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use std::sync::Arc;
|
||||
/// # use oxspeak_server_lib::event_bus::EventBus;
|
||||
/// # #[derive(Clone, Debug)] struct User { name: String }
|
||||
/// # let bus = Arc::new(EventBus::new());
|
||||
/// bus.on::<User>("user-connected", |user| {
|
||||
/// println!("Connected: {:?}", user);
|
||||
/// });
|
||||
/// ```
|
||||
pub fn on<T, F>(&self, topic: &str, handler: F) -> JoinHandle<()>
|
||||
where
|
||||
T: Any + Send + Sync + Clone + 'static,
|
||||
F: Fn(T) + Send + Sync + 'static,
|
||||
{
|
||||
let mut rx = self.get_or_create_sender(topic).subscribe();
|
||||
let topic_owned = topic.to_string();
|
||||
|
||||
debug!(topic, "Sync subscriber registered");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if let Some(typed) = evt.downcast_ref::<T>() {
|
||||
trace!(topic = topic_owned, "Sync handler invoked");
|
||||
handler(typed.clone());
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(
|
||||
topic = topic_owned,
|
||||
skipped = n,
|
||||
"Subscriber lagged, messages dropped"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(
|
||||
topic = topic_owned,
|
||||
"Channel closed, sync subscriber exiting"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Subscribes to a topic and calls an **async** handler on each event of type `T`.
|
||||
///
|
||||
/// Ideal for performing async operations in the handler
|
||||
/// (DB query, HTTP call, WebSocket broadcast, …).
|
||||
/// Returns a [`JoinHandle`] to cancel the subscription if needed.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use std::sync::Arc;
|
||||
/// # use oxspeak_server_lib::event_bus::EventBus;
|
||||
/// # #[derive(Clone, Debug)] struct User { name: String }
|
||||
/// # let bus = Arc::new(EventBus::new());
|
||||
/// bus.on_async::<User, _, _>("user-connected", |user| async move {
|
||||
/// println!("(async) Connected: {:?}", user);
|
||||
/// // async work here: DB query, HTTP, etc.
|
||||
/// });
|
||||
/// ```
|
||||
pub fn on_async<T, F, Fut>(&self, topic: &str, handler: F) -> JoinHandle<()>
|
||||
where
|
||||
T: Any + Send + Sync + Clone + 'static,
|
||||
F: Fn(T) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let mut rx = self.get_or_create_sender(topic).subscribe();
|
||||
let topic_owned = topic.to_string();
|
||||
|
||||
debug!(topic, "Async subscriber registered");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if let Some(typed) = evt.downcast_ref::<T>() {
|
||||
trace!(topic = topic_owned, "Async handler invoked");
|
||||
handler(typed.clone()).await;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(
|
||||
topic = topic_owned,
|
||||
skipped = n,
|
||||
"Subscriber lagged, messages dropped"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(
|
||||
topic = topic_owned,
|
||||
"Channel closed, async subscriber exiting"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// todo : Undocumented
|
||||
pub fn on_async_with<T, C, F, Fut>(&self, topic: &str, context: C, handler: F) -> JoinHandle<()>
|
||||
where
|
||||
T: Any + Send + Sync + Clone + 'static,
|
||||
C: Clone + Send + Sync + 'static,
|
||||
F: Fn(C, T) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let mut rx = self.get_or_create_sender(topic).subscribe();
|
||||
let topic_owned = topic.to_string();
|
||||
|
||||
debug!(topic, "Async subscriber registered");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(evt) => {
|
||||
if let Some(typed) = evt.downcast_ref::<T>() {
|
||||
trace!(topic = topic_owned, "Async handler invoked");
|
||||
|
||||
handler(context.clone(), typed.clone()).await;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
warn!(
|
||||
topic = topic_owned,
|
||||
skipped = n,
|
||||
"Subscriber lagged, messages dropped"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(
|
||||
topic = topic_owned,
|
||||
"Channel closed, async subscriber exiting"
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Subscription — low-level access (advanced use cases)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns a raw [`AnyEvent`] receiver to manage the loop yourself.
|
||||
///
|
||||
/// Useful with the [`match_event!`][crate::match_event] macro to handle
|
||||
/// multiple different types on the same topic.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use std::sync::Arc;
|
||||
/// # use oxspeak_server_lib::event_bus::EventBus;
|
||||
/// # use oxspeak_server_lib::match_event;
|
||||
/// # #[derive(Clone, Debug)] struct User { name: String }
|
||||
/// # #[derive(Clone, Debug)] struct UdpMetric { value: f32 }
|
||||
/// # let bus = Arc::new(EventBus::new());
|
||||
/// # tokio_test::block_on(async {
|
||||
/// let mut rx = bus.on_raw("user-connected");
|
||||
/// bus.emit("user-connected", User { name: "Alice".into() });
|
||||
///
|
||||
/// if let Ok(evt) = rx.recv().await {
|
||||
/// match_event!(evt,
|
||||
/// User => |u| println!("User: {:?}", u),
|
||||
/// UdpMetric => |m| println!("Metric: {:?}", m),
|
||||
/// );
|
||||
/// }
|
||||
/// # });
|
||||
/// ```
|
||||
pub fn on_raw(&self, topic: &str) -> broadcast::Receiver<AnyEvent> {
|
||||
debug!(topic, "Raw subscriber registered");
|
||||
self.get_or_create_sender(topic).subscribe()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the list of currently registered topics.
|
||||
pub fn topics(&self) -> Vec<String> {
|
||||
self.channels.read().keys().cloned().collect()
|
||||
}
|
||||
/// A central, strongly-typed broadcast event bus.
|
||||
///
|
||||
/// Unlike string/topic-based event buses, [`EventBus`] dispatches events directly
|
||||
/// based on the concrete Rust type of the event (using [`TypeId`]).
|
||||
///
|
||||
/// Under the hood, each event type is backed by an independent [`tokio::sync::broadcast`]
|
||||
/// ring buffer. Dispatching an event via [`emit`](Self::emit) is $O(1)$ and non-blocking.
|
||||
///
|
||||
/// # Sync callback example
|
||||
/// ```rust,no_run
|
||||
/// use event_bus::EventBus;
|
||||
///
|
||||
/// #[derive(Clone, Debug)]
|
||||
/// struct MessageCreatedEvent {
|
||||
/// content: String,
|
||||
/// }
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let bus = EventBus::new();
|
||||
///
|
||||
/// bus.on::<MessageCreatedEvent>(|event| {
|
||||
/// println!("Created message: {:?}", event);
|
||||
/// });
|
||||
///
|
||||
/// bus.emit(MessageCreatedEvent {
|
||||
/// content: "Hello!".into(),
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// # Async callback example
|
||||
/// ```rust,no_run
|
||||
/// use event_bus::EventBus;
|
||||
///
|
||||
/// #[derive(Clone, Debug)]
|
||||
/// struct MessageUpdatedEvent {
|
||||
/// id: u64,
|
||||
/// content: String,
|
||||
/// }
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let bus = EventBus::new();
|
||||
///
|
||||
/// bus.on_async::<MessageUpdatedEvent>(|event| async move {
|
||||
/// println!("(async) Updated message: {:?}", event);
|
||||
/// });
|
||||
///
|
||||
/// bus.emit(MessageUpdatedEvent {
|
||||
/// id: 42,
|
||||
/// content: "World!".into(),
|
||||
/// });
|
||||
/// }
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EventBus {
|
||||
inner: Arc<EventBusInner>,
|
||||
}
|
||||
|
||||
impl Default for EventBus {
|
||||
@@ -422,3 +92,342 @@ impl Default for EventBus {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl EventBus {
|
||||
/// Creates a new `EventBus` with default capacity (1024 messages per channel).
|
||||
pub fn new() -> Self {
|
||||
Self::with_capacity(DEFAULT_CAPACITY)
|
||||
}
|
||||
|
||||
/// Creates a new `EventBus` with custom buffer capacity per channel.
|
||||
pub fn with_capacity(capacity: usize) -> Self {
|
||||
debug!(capacity, "EventBus created");
|
||||
Self {
|
||||
inner: Arc::new(EventBusInner {
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
capacity,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Internal Channel Management
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn get_or_create_sender<E: Event>(&self) -> broadcast::Sender<E> {
|
||||
let type_id = TypeId::of::<E>();
|
||||
|
||||
if let Some(tx) = self
|
||||
.inner
|
||||
.channels
|
||||
.read()
|
||||
.get(&type_id)
|
||||
.and_then(|entry| entry.downcast_ref::<broadcast::Sender<E>>())
|
||||
{
|
||||
return tx.clone();
|
||||
}
|
||||
|
||||
let mut channels = self.inner.channels.write();
|
||||
if let Some(tx) = channels
|
||||
.get(&type_id)
|
||||
.and_then(|entry| entry.downcast_ref::<broadcast::Sender<E>>())
|
||||
{
|
||||
return tx.clone();
|
||||
}
|
||||
|
||||
let (tx, _) = broadcast::channel::<E>(self.inner.capacity);
|
||||
debug!(
|
||||
event_type = std::any::type_name::<E>(),
|
||||
capacity = self.inner.capacity,
|
||||
"New broadcast channel created"
|
||||
);
|
||||
channels.insert(type_id, Box::new(tx.clone()));
|
||||
tx
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Emission
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Emits a strongly-typed event to all subscribers of `E` without blocking ($O(1)$).
|
||||
///
|
||||
/// If no subscribers exist for this event type, the event is dropped immediately
|
||||
/// without cloning or allocating.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use event_bus::EventBus;
|
||||
/// # #[derive(Clone)] struct UserConnected { id: u64 }
|
||||
/// # let bus = EventBus::new();
|
||||
/// bus.emit(UserConnected { id: 1 });
|
||||
/// ```
|
||||
pub fn emit<E: Event>(&self, event: E) {
|
||||
trace!(
|
||||
event_type = std::any::type_name::<E>(),
|
||||
"Emitting event"
|
||||
);
|
||||
|
||||
let type_id = TypeId::of::<E>();
|
||||
|
||||
if let Some(tx) = self
|
||||
.inner
|
||||
.channels
|
||||
.read()
|
||||
.get(&type_id)
|
||||
.and_then(|entry| entry.downcast_ref::<broadcast::Sender<E>>())
|
||||
.filter(|tx| tx.receiver_count() > 0)
|
||||
{
|
||||
let _ = tx.send(event);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Subscriptions — Callbacks
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Registers a synchronous callback for events of type `E`.
|
||||
///
|
||||
/// The handler runs in a dedicated background Tokio task.
|
||||
/// Returns a [`JoinHandle`] allowing to cancel the subscription via [`.abort()`](JoinHandle::abort).
|
||||
pub fn on<E: Event>(&self, handler: impl Fn(E) + Send + Sync + 'static) -> JoinHandle<()> {
|
||||
let mut rx = self.subscribe::<E>();
|
||||
let type_name = std::any::type_name::<E>();
|
||||
|
||||
debug!(event_type = type_name, "Sync subscriber registered");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
trace!(event_type = type_name, "Sync handler invoked");
|
||||
handler(event);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
warn!(
|
||||
event_type = type_name,
|
||||
skipped,
|
||||
"Subscriber lagged behind and skipped messages"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(event_type = type_name, "Channel closed, subscriber exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers an asynchronous callback for events of type `E`.
|
||||
///
|
||||
/// The handler runs in a dedicated background Tokio task.
|
||||
/// Returns a [`JoinHandle`] allowing to cancel the subscription via [`.abort()`](JoinHandle::abort).
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use event_bus::EventBus;
|
||||
/// # #[derive(Clone)] struct MessageUpdatedEvent { id: u64 }
|
||||
/// # let bus = EventBus::new();
|
||||
/// bus.on_async::<MessageUpdatedEvent>(|event| async move {
|
||||
/// println!("Updated: {:?}", event.id);
|
||||
/// });
|
||||
/// ```
|
||||
pub fn on_async<E: Event>(&self, handler: impl AsyncHandler<E>) -> JoinHandle<()> {
|
||||
let mut rx = self.subscribe::<E>();
|
||||
let type_name = std::any::type_name::<E>();
|
||||
|
||||
debug!(event_type = type_name, "Async subscriber registered");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
trace!(event_type = type_name, "Async handler invoked");
|
||||
handler(event).await;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
warn!(
|
||||
event_type = type_name,
|
||||
skipped,
|
||||
"Subscriber lagged behind and skipped messages"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(event_type = type_name, "Channel closed, subscriber exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Registers an asynchronous callback for events of type `E` with an injected context `C`.
|
||||
pub fn on_async_with<E: Event, C: Clone + Send + Sync + 'static>(
|
||||
&self,
|
||||
context: C,
|
||||
handler: impl AsyncHandlerWith<E, C>,
|
||||
) -> JoinHandle<()> {
|
||||
let mut rx = self.subscribe::<E>();
|
||||
let type_name = std::any::type_name::<E>();
|
||||
|
||||
debug!(event_type = type_name, "Async subscriber with context registered");
|
||||
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
trace!(event_type = type_name, "Async handler with context invoked");
|
||||
handler(context.clone(), event).await;
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
warn!(
|
||||
event_type = type_name,
|
||||
skipped,
|
||||
"Subscriber lagged behind and skipped messages"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
debug!(event_type = type_name, "Channel closed, subscriber exiting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Low-Level Subscription (Direct Stream / Receiver)
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns a direct [`broadcast::Receiver<E>`] for events of type `E`.
|
||||
///
|
||||
/// Allows writing custom event processing loops without callback wrappers.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// use event_bus::EventBus;
|
||||
///
|
||||
/// #[derive(Clone)]
|
||||
/// struct MyEvent;
|
||||
///
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let bus = EventBus::new();
|
||||
/// let mut rx = bus.subscribe::<MyEvent>();
|
||||
/// bus.emit(MyEvent);
|
||||
/// if let Ok(event) = rx.recv().await {
|
||||
/// // direct typed `event`
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
pub fn subscribe<E: Event>(&self) -> broadcast::Receiver<E> {
|
||||
self.get_or_create_sender::<E>().subscribe()
|
||||
}
|
||||
|
||||
/// Waits for the next event of type `E` to be emitted.
|
||||
///
|
||||
/// Creates a temporary one-shot subscription and resolves as soon as an event of type `E`
|
||||
/// is emitted. The subscription is automatically dropped after receiving the event.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use event_bus::EventBus;
|
||||
/// # #[derive(Clone)] struct MyEvent { id: u64 }
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let bus = EventBus::new();
|
||||
/// let event = bus.wait_next::<MyEvent>().await.unwrap();
|
||||
/// println!("Received next event: {}", event.id);
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn wait_next<E: Event>(&self) -> Result<E, broadcast::error::RecvError> {
|
||||
let mut rx = self.subscribe::<E>();
|
||||
rx.recv().await
|
||||
}
|
||||
|
||||
/// Waits for an event of type `E` satisfying the given predicate to be emitted.
|
||||
///
|
||||
/// Creates a temporary subscription, receives events of type `E`, and resolves
|
||||
/// when the predicate returns `true`. The subscription is automatically dropped afterwards.
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use event_bus::EventBus;
|
||||
/// # #[derive(Clone)] struct MessageSaved { id: u64 }
|
||||
/// #[tokio::main]
|
||||
/// async fn main() {
|
||||
/// let bus = EventBus::new();
|
||||
/// let target_id = 42;
|
||||
/// let event = bus.wait_for::<MessageSaved>(|e| e.id == target_id).await.unwrap();
|
||||
/// println!("Saved message confirmed: {}", event.id);
|
||||
/// }
|
||||
/// ```
|
||||
pub async fn wait_for<E: Event>(
|
||||
&self,
|
||||
mut predicate: impl FnMut(&E) -> bool,
|
||||
) -> Result<E, broadcast::error::RecvError> {
|
||||
let mut rx = self.subscribe::<E>();
|
||||
loop {
|
||||
match rx.recv().await {
|
||||
Ok(event) => {
|
||||
if predicate(&event) {
|
||||
return Ok(event);
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(skipped)) => {
|
||||
warn!(
|
||||
event_type = std::any::type_name::<E>(),
|
||||
skipped,
|
||||
"wait_for subscriber lagged behind and skipped messages"
|
||||
);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => {
|
||||
return Err(broadcast::error::RecvError::Closed);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// Metrics & Utilities
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Returns the total number of active subscribers for event type `E`.
|
||||
pub fn subscriber_count<E: Event>(&self) -> usize {
|
||||
let type_id = TypeId::of::<E>();
|
||||
|
||||
self.inner
|
||||
.channels
|
||||
.read()
|
||||
.get(&type_id)
|
||||
.and_then(|e| e.downcast_ref::<broadcast::Sender<E>>())
|
||||
.map(|tx| tx.receiver_count())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Returns `true` if there are any active subscribers for event type `E`.
|
||||
pub fn has_subscribers<E: Event>(&self) -> bool {
|
||||
self.subscriber_count::<E>() > 0
|
||||
}
|
||||
|
||||
/// Returns the total number of broadcast channels currently instantiated in the bus.
|
||||
pub fn channel_count(&self) -> usize {
|
||||
self.inner.channels.read().len()
|
||||
}
|
||||
|
||||
/// Returns `true` if no channels are currently registered in the bus.
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.inner.channels.read().is_empty()
|
||||
}
|
||||
|
||||
/// Removes and drops all channels in the event bus.
|
||||
pub fn clear(&self) {
|
||||
self.inner.channels.write().clear();
|
||||
}
|
||||
|
||||
/// Removes and drops the channel associated with event type `E`.
|
||||
pub fn clear_type<E: Event>(&self) {
|
||||
let type_id = TypeId::of::<E>();
|
||||
self.inner.channels.write().remove(&type_id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
use std::future::Future;
|
||||
|
||||
/// Handler trait for asynchronous event callbacks.
|
||||
///
|
||||
/// This trait is automatically implemented for any closure matching
|
||||
/// `Fn(E) -> Future<Output = ()> + Send + Sync + 'static`.
|
||||
///
|
||||
/// By using `Fn(E) -> <Self as AsyncHandler<E>>::Fut` as a supertrait,
|
||||
/// Rust propagates the event type `E` directly into the closure's parameter,
|
||||
/// enabling seamless turbofish syntax with clean field access:
|
||||
///
|
||||
/// ```rust,no_run
|
||||
/// # use event_bus::EventBus;
|
||||
/// # #[derive(Clone)] struct MessageUpdatedEvent { id: u64 }
|
||||
/// # let bus = EventBus::new();
|
||||
/// bus.on_async::<MessageUpdatedEvent>(|event| async move {
|
||||
/// println!("Updated: {:?}", event.id);
|
||||
/// });
|
||||
/// ```
|
||||
pub trait AsyncHandler<E>: Fn(E) -> <Self as AsyncHandler<E>>::Fut + Send + Sync + 'static {
|
||||
type Fut: Future<Output = ()> + Send + 'static;
|
||||
}
|
||||
|
||||
impl<E, F, Fut> AsyncHandler<E> for F
|
||||
where
|
||||
F: Fn(E) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
type Fut = Fut;
|
||||
}
|
||||
|
||||
/// Handler trait for asynchronous event callbacks with an injected context.
|
||||
///
|
||||
/// Automatically implemented for closures matching
|
||||
/// `Fn(C, E) -> Future<Output = ()> + Send + Sync + 'static`.
|
||||
pub trait AsyncHandlerWith<E, C>: Fn(C, E) -> <Self as AsyncHandlerWith<E, C>>::Fut + Send + Sync + 'static {
|
||||
type Fut: Future<Output = ()> + Send + 'static;
|
||||
}
|
||||
|
||||
impl<E, C, F, Fut> AsyncHandlerWith<E, C> for F
|
||||
where
|
||||
C: Clone + Send + Sync + 'static,
|
||||
F: Fn(C, E) -> Fut + Send + Sync + 'static,
|
||||
Fut: Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
type Fut = Fut;
|
||||
}
|
||||
+44
-44
@@ -1,49 +1,49 @@
|
||||
/// Downcasts an [`AnyEvent`] to one or more concrete types and executes
|
||||
/// the matching closure if the type matches.
|
||||
///
|
||||
/// Non-matching branches are silently ignored.
|
||||
///
|
||||
/// # Syntax
|
||||
/// ```text
|
||||
/// match_event!(evt, Type1 => |val| { ... }, Type2 => |val| { ... })
|
||||
/// ```
|
||||
///
|
||||
/// # Example
|
||||
/// ```rust,no_run
|
||||
/// # use std::sync::Arc;
|
||||
/// # use oxspeak_server_lib::event_bus::EventBus;
|
||||
/// # use oxspeak_server_lib::match_event;
|
||||
/// # #[derive(Clone, Debug)] struct User { name: String }
|
||||
/// # #[derive(Clone, Debug)] struct UdpMetric { value: f32 }
|
||||
/// # let bus = Arc::new(EventBus::new());
|
||||
/// # tokio_test::block_on(async {
|
||||
/// let mut rx = bus.on_raw("user-connected");
|
||||
/// bus.emit("user-connected", User { name: "Alice".into() });
|
||||
///
|
||||
/// if let Ok(evt) = rx.recv().await {
|
||||
/// match_event!(evt,
|
||||
/// User => |u| println!("User: {:?}", u),
|
||||
/// UdpMetric => |m| println!("Metric: {:?}", m),
|
||||
/// );
|
||||
/// }
|
||||
/// # });
|
||||
/// ```
|
||||
#[macro_export]
|
||||
macro_rules! match_event {
|
||||
($evt:expr, $($type:ty => $handler:expr),+ $(,)?) => {
|
||||
$(
|
||||
if let Some(val) = ($evt).downcast_ref::<$type>() {
|
||||
($handler)(val.clone());
|
||||
} else
|
||||
)+
|
||||
{
|
||||
// No matching type → silently ignored
|
||||
}
|
||||
};
|
||||
}
|
||||
//! # event_bus
|
||||
//!
|
||||
//! A strongly-typed, high-performance in-memory event bus for Tokio.
|
||||
//!
|
||||
//! ## Overview
|
||||
//!
|
||||
//! Unlike string/topic-based event buses, `event_bus` routes events using
|
||||
//! their concrete Rust types ([`std::any::TypeId`]).
|
||||
//!
|
||||
//! - **Strong typing**: No string keys required for event types, no manual `match_event!`
|
||||
//! macros, and no runtime downcasting (`downcast_ref`) inside the subscriber loops.
|
||||
//! - **Ergonomic async subscribers**: Handlers can be registered with clean turbofish syntax:
|
||||
//! `bus.on_async::<MessageUpdatedEvent>(|event| async move { ... })`.
|
||||
//! - **Targeted wake-up**: Tokio broadcast channels are isolated per event type.
|
||||
//!
|
||||
//! ## Example
|
||||
//!
|
||||
//! ```rust,no_run
|
||||
//! use event_bus::EventBus;
|
||||
//!
|
||||
//! #[derive(Clone, Debug, PartialEq)]
|
||||
//! struct MessageCreatedEvent {
|
||||
//! content: String,
|
||||
//! }
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! let bus = EventBus::new();
|
||||
//!
|
||||
//! // Async subscriber
|
||||
//! bus.on_async::<MessageCreatedEvent>(|event| async move {
|
||||
//! println!("Received message: {}", event.content);
|
||||
//! });
|
||||
//!
|
||||
//! // Emit event
|
||||
//! bus.emit(MessageCreatedEvent {
|
||||
//! content: "Hello from typed event bus!".into(),
|
||||
//! });
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
mod bus;
|
||||
pub use bus::{AnyEvent, EventBus, Scope, ScopeValue};
|
||||
mod handler;
|
||||
|
||||
pub use bus::{DEFAULT_CAPACITY, Event, EventBus};
|
||||
pub use handler::{AsyncHandler, AsyncHandlerWith};
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
+314
-142
@@ -1,192 +1,364 @@
|
||||
use crate::{EventBus, match_event};
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::EventBus;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct User {
|
||||
name: String,
|
||||
struct MessageCreatedEvent {
|
||||
channel_id: Uuid,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct UdpMetric {
|
||||
struct MessageUpdatedEvent {
|
||||
id: u64,
|
||||
content: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct MessageDeletedEvent {
|
||||
id: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
struct UdpMetricEvent {
|
||||
value: f32,
|
||||
}
|
||||
|
||||
// ── on (callback sync) ────────────────────────────────────────────────────
|
||||
// ── Sync Callbacks ──────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_callback_sync() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let bus = EventBus::new();
|
||||
let received = Arc::new(AtomicBool::new(false));
|
||||
let flag = Arc::clone(&received);
|
||||
|
||||
bus.on::<User, _>("user-connected", move |user| {
|
||||
if user.name == "Alice" {
|
||||
bus.on::<MessageCreatedEvent>(move |event| {
|
||||
if event.content == "Hello" {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
bus.emit(
|
||||
"user-connected",
|
||||
User {
|
||||
name: "Alice".into(),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
bus.emit(MessageCreatedEvent {
|
||||
channel_id: Uuid::new_v4(),
|
||||
content: "Hello".into(),
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert!(received.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_targeted_wakeup() {
|
||||
// Émettre sur "user-connected" ne doit pas réveiller "udp-metrics-updated"
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let metric_called = Arc::new(AtomicBool::new(false));
|
||||
let flag = Arc::clone(&metric_called);
|
||||
|
||||
bus.on::<UdpMetric, _>("udp-metrics-updated", move |_| {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
bus.emit(
|
||||
"user-connected",
|
||||
User {
|
||||
name: "Carol".into(),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
|
||||
assert!(!metric_called.load(Ordering::SeqCst));
|
||||
}
|
||||
// ── Async Callbacks (Exact User Requirement) ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_type_mismatch_ignored() {
|
||||
// Émettre un UdpMetric sur un topic écouté en User → handler pas appelé
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let called = Arc::new(AtomicBool::new(false));
|
||||
let flag = Arc::clone(&called);
|
||||
async fn test_on_async_callback_turbofish() {
|
||||
let bus = EventBus::new();
|
||||
let received_content = Arc::new(tokio::sync::Mutex::new(String::new()));
|
||||
let rc = Arc::clone(&received_content);
|
||||
|
||||
bus.on::<User, _>("mixed-topic", move |_| {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
bus.emit("mixed-topic", UdpMetric { value: 1.0 });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
|
||||
assert!(!called.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_multiple_subscribers_same_topic() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
|
||||
for _ in 0..3 {
|
||||
let c = Arc::clone(&count);
|
||||
bus.on::<User, _>("user-connected", move |_| {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
bus.emit(
|
||||
"user-connected",
|
||||
User {
|
||||
name: "Grace".into(),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
|
||||
assert_eq!(count.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
// ── on_async ──────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_async_callback() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let received = Arc::new(AtomicBool::new(false));
|
||||
let flag = Arc::clone(&received);
|
||||
|
||||
bus.on_async::<User, _, _>("user-connected", move |user| {
|
||||
let f = Arc::clone(&flag);
|
||||
// Exact syntax specified by the user:
|
||||
// event_bus.on_async::<MessageUpdatedEvent>(|event| async move { ... });
|
||||
bus.on_async::<MessageUpdatedEvent>(move |event| {
|
||||
let rc = Arc::clone(&rc);
|
||||
async move {
|
||||
if user.name == "Async" {
|
||||
let mut lock = rc.lock().await;
|
||||
*lock = event.content;
|
||||
}
|
||||
});
|
||||
|
||||
// Exact syntax specified by the user:
|
||||
// event_bus.emit(MessageUpdatedEvent { ... });
|
||||
bus.emit(MessageUpdatedEvent {
|
||||
id: 42,
|
||||
content: "Updated message content".into(),
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
|
||||
let result = received_content.lock().await.clone();
|
||||
assert_eq!(result, "Updated message content");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_async_callback_type_inferred() {
|
||||
let bus = EventBus::new();
|
||||
let flag = Arc::new(AtomicBool::new(false));
|
||||
let f = Arc::clone(&flag);
|
||||
|
||||
// Also supports inferring the event type from closure parameter
|
||||
bus.on_async(move |event: MessageUpdatedEvent| {
|
||||
let f = Arc::clone(&f);
|
||||
async move {
|
||||
if event.id == 99 {
|
||||
f.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
bus.emit(
|
||||
"user-connected",
|
||||
User {
|
||||
name: "Async".into(),
|
||||
},
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
bus.emit(MessageUpdatedEvent {
|
||||
id: 99,
|
||||
content: "Inferred".into(),
|
||||
});
|
||||
|
||||
assert!(received.load(Ordering::SeqCst));
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert!(flag.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ── on_raw + match_event! ─────────────────────────────────────────────────
|
||||
// ── Targeted Wake-Up & Type Isolation ───────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_raw_and_match_event_macro() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let mut rx = bus.on_raw("user-connected");
|
||||
|
||||
bus.emit(
|
||||
"user-connected",
|
||||
User {
|
||||
name: "Frank".into(),
|
||||
},
|
||||
);
|
||||
|
||||
let evt = rx.recv().await.unwrap();
|
||||
let mut received_name = String::new();
|
||||
match_event!(evt,
|
||||
User => |u: User| { received_name = u.name.clone(); },
|
||||
UdpMetric => |_m: UdpMetric| { panic!("mauvais type"); }
|
||||
);
|
||||
assert_eq!(received_name, "Frank");
|
||||
}
|
||||
|
||||
// ── Utilitaires ────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_topics_list() {
|
||||
async fn test_on_targeted_wakeup() {
|
||||
let bus = EventBus::new();
|
||||
// on_raw enregistre le canal (get_or_create)
|
||||
let _rx1 = bus.on_raw("user-connected");
|
||||
let _rx2 = bus.on_raw("udp-metrics-updated");
|
||||
let metric_called = Arc::new(AtomicBool::new(false));
|
||||
let flag = Arc::clone(&metric_called);
|
||||
|
||||
let mut topics = bus.topics();
|
||||
topics.sort();
|
||||
assert_eq!(topics, vec!["udp-metrics-updated", "user-connected"]);
|
||||
bus.on::<UdpMetricEvent>(move |_| {
|
||||
flag.store(true, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
// Emitting MessageCreatedEvent must never wake up UdpMetricEvent subscribers
|
||||
bus.emit(MessageCreatedEvent {
|
||||
channel_id: Uuid::new_v4(),
|
||||
content: "Ignore me".into(),
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert!(!metric_called.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_emit_multiple_types_same_bus() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let user_ok = Arc::new(AtomicBool::new(false));
|
||||
let metric_ok = Arc::new(AtomicBool::new(false));
|
||||
let u = Arc::clone(&user_ok);
|
||||
let m = Arc::clone(&metric_ok);
|
||||
async fn test_multiple_subscribers_same_type() {
|
||||
let bus = EventBus::new();
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
|
||||
bus.on::<User, _>("user-connected", move |user| {
|
||||
if user.name == "Bob" {
|
||||
u.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
bus.on::<UdpMetric, _>("udp-metrics-updated", move |metric| {
|
||||
if (metric.value - 3.14).abs() < 0.001 {
|
||||
m.store(true, Ordering::SeqCst);
|
||||
}
|
||||
for _ in 0..3 {
|
||||
let c = Arc::clone(&count);
|
||||
bus.on::<MessageCreatedEvent>(move |_| {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
}
|
||||
|
||||
bus.emit(MessageCreatedEvent {
|
||||
channel_id: Uuid::new_v4(),
|
||||
content: "Broadcast".into(),
|
||||
});
|
||||
|
||||
bus.emit("user-connected", User { name: "Bob".into() });
|
||||
bus.emit("udp-metrics-updated", UdpMetric { value: 3.14 });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert_eq!(count.load(Ordering::SeqCst), 3);
|
||||
}
|
||||
|
||||
assert!(user_ok.load(Ordering::SeqCst));
|
||||
#[tokio::test]
|
||||
async fn test_multiple_different_types_same_bus() {
|
||||
let bus = EventBus::new();
|
||||
let msg_ok = Arc::new(AtomicBool::new(false));
|
||||
let metric_ok = Arc::new(AtomicBool::new(false));
|
||||
let m_flag = Arc::clone(&msg_ok);
|
||||
let u_flag = Arc::clone(&metric_ok);
|
||||
|
||||
bus.on::<MessageCreatedEvent>(move |event| {
|
||||
if event.content == "Test" {
|
||||
m_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
bus.on::<UdpMetricEvent>(move |metric| {
|
||||
if (metric.value - 42.5).abs() < 0.001 {
|
||||
u_flag.store(true, Ordering::SeqCst);
|
||||
}
|
||||
});
|
||||
|
||||
bus.emit(MessageCreatedEvent {
|
||||
channel_id: Uuid::new_v4(),
|
||||
content: "Test".into(),
|
||||
});
|
||||
bus.emit(UdpMetricEvent { value: 42.5 });
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert!(msg_ok.load(Ordering::SeqCst));
|
||||
assert!(metric_ok.load(Ordering::SeqCst));
|
||||
}
|
||||
|
||||
// ── Direct Stream / Receiver (No match_event! needed) ───────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subscribe_direct_typed_receiver() {
|
||||
let bus = EventBus::new();
|
||||
let mut rx = bus.subscribe::<MessageUpdatedEvent>();
|
||||
|
||||
bus.emit(MessageUpdatedEvent {
|
||||
id: 123,
|
||||
content: "Direct typed".into(),
|
||||
});
|
||||
|
||||
let event = rx.recv().await.expect("failed to receive event");
|
||||
// event is directly of type MessageUpdatedEvent, no downcast needed!
|
||||
assert_eq!(event.id, 123);
|
||||
assert_eq!(event.content, "Direct typed");
|
||||
}
|
||||
|
||||
// ── In-Handler Filtering (Direct Field Access) ──────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_filter_by_field_in_subscriber() {
|
||||
let bus = EventBus::new();
|
||||
let channel_a = Uuid::new_v4();
|
||||
let channel_b = Uuid::new_v4();
|
||||
|
||||
let count_a = Arc::new(AtomicU32::new(0));
|
||||
let count_b = Arc::new(AtomicU32::new(0));
|
||||
let count_global = Arc::new(AtomicU32::new(0));
|
||||
|
||||
let ca = Arc::clone(&count_a);
|
||||
bus.on_async::<MessageCreatedEvent>(move |event| {
|
||||
let ca = Arc::clone(&ca);
|
||||
async move {
|
||||
if event.channel_id == channel_a {
|
||||
ca.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cb = Arc::clone(&count_b);
|
||||
bus.on_async::<MessageCreatedEvent>(move |event| {
|
||||
let cb = Arc::clone(&cb);
|
||||
async move {
|
||||
if event.channel_id == channel_b {
|
||||
cb.fetch_add(1, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let cg = Arc::clone(&count_global);
|
||||
bus.on::<MessageCreatedEvent>(move |_| {
|
||||
cg.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
// Emit event with channel_a
|
||||
bus.emit(MessageCreatedEvent {
|
||||
channel_id: channel_a,
|
||||
content: "For A".into(),
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
|
||||
// Both channel_a handler and global handler processed it, but not channel_b
|
||||
assert_eq!(count_a.load(Ordering::SeqCst), 1);
|
||||
assert_eq!(count_b.load(Ordering::SeqCst), 0);
|
||||
assert_eq!(count_global.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ── Async with Context ──────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_on_async_with_context() {
|
||||
let bus = EventBus::new();
|
||||
let prefix = Arc::new("Prefix: ".to_string());
|
||||
let result = Arc::new(tokio::sync::Mutex::new(String::new()));
|
||||
let r = Arc::clone(&result);
|
||||
|
||||
bus.on_async_with::<MessageCreatedEvent, _>(prefix, move |ctx, event| {
|
||||
let r = Arc::clone(&r);
|
||||
async move {
|
||||
let mut lock = r.lock().await;
|
||||
*lock = format!("{}{}", ctx, event.content);
|
||||
}
|
||||
});
|
||||
|
||||
bus.emit(MessageCreatedEvent {
|
||||
channel_id: Uuid::new_v4(),
|
||||
content: "Hello Context".into(),
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
let final_str = result.lock().await.clone();
|
||||
assert_eq!(final_str, "Prefix: Hello Context");
|
||||
}
|
||||
|
||||
// ── Metrics, Utilities & Cleanup ────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn test_subscriber_count_and_clear() {
|
||||
let bus = EventBus::new();
|
||||
assert_eq!(bus.subscriber_count::<MessageCreatedEvent>(), 0);
|
||||
assert!(!bus.has_subscribers::<MessageCreatedEvent>());
|
||||
|
||||
let _sub = bus.subscribe::<MessageCreatedEvent>();
|
||||
assert_eq!(bus.subscriber_count::<MessageCreatedEvent>(), 1);
|
||||
assert!(bus.has_subscribers::<MessageCreatedEvent>());
|
||||
assert_eq!(bus.channel_count(), 1);
|
||||
|
||||
bus.clear_type::<MessageCreatedEvent>();
|
||||
assert_eq!(bus.subscriber_count::<MessageCreatedEvent>(), 0);
|
||||
assert_eq!(bus.channel_count(), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_subscription_abort() {
|
||||
let bus = EventBus::new();
|
||||
let count = Arc::new(AtomicU32::new(0));
|
||||
let c = Arc::clone(&count);
|
||||
|
||||
let handle = bus.on::<MessageDeletedEvent>(move |_| {
|
||||
c.fetch_add(1, Ordering::SeqCst);
|
||||
});
|
||||
|
||||
bus.emit(MessageDeletedEvent { id: 1 });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
|
||||
// Cancel the subscription
|
||||
handle.abort();
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
|
||||
bus.emit(MessageDeletedEvent { id: 2 });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
|
||||
// Count should not increase after abort
|
||||
assert_eq!(count.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
// ── One-Shot Listeners (wait_next & wait_for) ────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_next() {
|
||||
let bus = EventBus::new();
|
||||
let b = bus.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
b.emit(MessageUpdatedEvent {
|
||||
id: 777,
|
||||
content: "Next event".into(),
|
||||
});
|
||||
});
|
||||
|
||||
let event = bus.wait_next::<MessageUpdatedEvent>().await.unwrap();
|
||||
assert_eq!(event.id, 777);
|
||||
assert_eq!(event.content, "Next event");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_wait_for() {
|
||||
let bus = EventBus::new();
|
||||
let b = bus.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
b.emit(MessageUpdatedEvent {
|
||||
id: 1,
|
||||
content: "Ignore".into(),
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
|
||||
b.emit(MessageUpdatedEvent {
|
||||
id: 2,
|
||||
content: "Target".into(),
|
||||
});
|
||||
});
|
||||
|
||||
let event = bus
|
||||
.wait_for::<MessageUpdatedEvent>(|e| e.id == 2)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(event.id, 2);
|
||||
assert_eq!(event.content, "Target");
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"eslint-config-vuetify": "^4.3.4",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"sass-embedded": "^1.98.0",
|
||||
"smol-toml": "^1.9.0",
|
||||
"typescript": "~5.9.3",
|
||||
"unplugin-fonts": "^1.4.0",
|
||||
"vite": "^8.0.0",
|
||||
|
||||
@@ -7,8 +7,10 @@ import ContextMenu from "@/components/ContextMenu.vue";
|
||||
import UserListDrawer from '@/components/UserListDrawer.vue'
|
||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||
import {useContextMenu} from '@/composables/useContextMenu'
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
const serverStore = useServerStore()
|
||||
const voiceStore = useVoiceStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const {openContextMenu} = useContextMenu()
|
||||
@@ -212,6 +214,14 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
||||
/>
|
||||
|
||||
<router-view/>
|
||||
<div v-if="voiceStore.channelId" class="voice-controls pa-3 elevation-4 bg-surface">
|
||||
<span class="text-success font-weight-bold"><v-icon icon="mdi-volume-high" /> Vocal connecté</span>
|
||||
<v-btn size="small" prepend-icon="mdi-phone-hangup" @click="voiceStore.leave()">Quitter</v-btn>
|
||||
</div>
|
||||
<v-snackbar :model-value="!!voiceStore.error" color="error" @update:model-value="voiceStore.error = null">
|
||||
{{ voiceStore.error }}
|
||||
<template #actions><v-btn @click="voiceStore.error = null">Fermer</v-btn></template>
|
||||
</v-snackbar>
|
||||
<!-- Menu contextuel global -->
|
||||
<ContextMenu/>
|
||||
<v-dialog v-model="showDialog" width="400">
|
||||
@@ -284,6 +294,17 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-controls {
|
||||
position: fixed;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.space-y-4 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -60,7 +60,7 @@ async function handleRegister() {
|
||||
throw new Error(errData.message || 'Échec de l\'inscription')
|
||||
}
|
||||
|
||||
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : '/login')
|
||||
await router.push(serverId ? {name: 'login', query: {redirect: `/server/${serverId}`}} : {name: 'login'})
|
||||
} catch (err) {
|
||||
error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,361 @@
|
||||
<script lang="ts" setup>
|
||||
import {onUnmounted, ref} from 'vue'
|
||||
|
||||
const channelId = ref('')
|
||||
const logs = ref<string[]>([])
|
||||
const audioStats = ref<string[]>([])
|
||||
const connected = ref(false)
|
||||
const audioFile = ref<File | null>(null)
|
||||
const sourceLevel = ref(0)
|
||||
const roomLevel = ref(0)
|
||||
let socket: WebSocket | null = null
|
||||
let peer: RTCPeerConnection | null = null
|
||||
let microphone: MediaStream | null = null
|
||||
let microphoneContext: AudioContext | null = null
|
||||
let microphoneAnalyser: AnalyserNode | null = null
|
||||
let roomContext: AudioContext | null = null
|
||||
const roomAnalysers = new Map<MediaStreamTrack, AnalyserNode>()
|
||||
let fileAudio: HTMLAudioElement | null = null
|
||||
let fileUrl: string | null = null
|
||||
let fileStream: MediaStream | null = null
|
||||
let pendingCandidates: string[] = []
|
||||
let audioStatsTimer: ReturnType<typeof setInterval> | null = null
|
||||
let levelTimer: ReturnType<typeof setInterval> | null = null
|
||||
const remoteAudio = ref<HTMLDivElement | null>(null)
|
||||
const players = new Map<MediaStreamTrack, HTMLAudioElement>()
|
||||
let roomStream: MediaStream | null = null
|
||||
const sourceTracks = new Map<string, MediaStreamTrack>()
|
||||
|
||||
function rms(analyser: AnalyserNode | null, context: AudioContext | null) {
|
||||
if (!analyser || context?.state !== 'running') return 0
|
||||
const samples = new Float32Array(analyser.fftSize)
|
||||
analyser.getFloatTimeDomainData(samples)
|
||||
return Math.sqrt(samples.reduce((sum, sample) => sum + sample * sample, 0) / samples.length)
|
||||
}
|
||||
|
||||
function updateLevels() {
|
||||
sourceLevel.value = Math.min(1, Math.sqrt(rms(microphoneAnalyser, microphoneContext)))
|
||||
roomLevel.value = Math.min(1, Math.sqrt([...roomAnalysers.values()].reduce((sum, analyser) => sum + rms(analyser, roomContext) ** 2, 0)))
|
||||
}
|
||||
|
||||
function removeRoomTrack(track: MediaStreamTrack) {
|
||||
roomStream?.removeTrack(track)
|
||||
const player = players.get(track)
|
||||
if (player) {
|
||||
player.pause()
|
||||
player.srcObject = null
|
||||
player.remove()
|
||||
players.delete(track)
|
||||
}
|
||||
roomAnalysers.get(track)?.disconnect()
|
||||
roomAnalysers.delete(track)
|
||||
roomLevel.value = 0
|
||||
}
|
||||
|
||||
function log(message: string) {
|
||||
logs.value.push(`${new Date().toLocaleTimeString()} ${message}`)
|
||||
}
|
||||
|
||||
async function logSelectedIcePair(connection: RTCPeerConnection) {
|
||||
const stats = await connection.getStats()
|
||||
const pair = [...stats.values()].find(report => report.type === 'candidate-pair' && report.nominated && report.state === 'succeeded')
|
||||
if (!pair) {
|
||||
log('Paire ICE sélectionnée introuvable dans les statistiques.')
|
||||
return
|
||||
}
|
||||
const local = stats.get(pair.localCandidateId)
|
||||
const remote = stats.get(pair.remoteCandidateId)
|
||||
log(`Paire ICE sélectionnée : local ${local?.address ?? local?.ip ?? '?'}:${local?.port ?? '?'} (${local?.candidateType ?? '?'}) → distant ${remote?.address ?? remote?.ip ?? '?'}:${remote?.port ?? '?'} (${remote?.candidateType ?? '?'})`)
|
||||
}
|
||||
|
||||
async function logAudioStats(connection: RTCPeerConnection) {
|
||||
const stats = await connection.getStats()
|
||||
if (peer !== connection) return
|
||||
const sent = [...stats.values()].find(report => report.type === 'outbound-rtp' && report.kind === 'audio')
|
||||
const activeTracks = new Set(roomStream?.getAudioTracks().map(track => track.id) ?? [])
|
||||
const inbound = [...stats.values()].filter(report => report.type === 'inbound-rtp' && report.kind === 'audio' && activeTracks.has(report.trackIdentifier))
|
||||
const received = inbound.find(report => (report.packetsReceived ?? 0) > 0) ?? inbound[0]
|
||||
const lines = [`Audio : envoyé ${sent?.packetsSent ?? '?'} paquets / ${sent?.bytesSent ?? '?'} octets ; reçu ${inbound.length ? inbound.reduce((sum, report) => sum + (report.packetsReceived ?? 0), 0) : '?'} paquets / ${inbound.length ? inbound.reduce((sum, report) => sum + (report.bytesReceived ?? 0), 0) : '?'} octets (${inbound.length} piste(s))`]
|
||||
const level = (value: number | undefined) => typeof value === 'number' ? value.toFixed(3) : '?'
|
||||
const micLevel = microphoneAnalyser && microphoneContext?.state === 'running' ? rms(microphoneAnalyser, microphoneContext) : undefined
|
||||
lines.push(`Niveaux audio (0 à 1) : source RMS ${level(micLevel)} ; retour ${level(received?.audioLevel)} ; pertes ${received?.packetsLost ?? '?'} ; gigue ${received?.jitter ?? '?'} s ; échantillons masqués ${received?.concealedSamples ?? '?'}/${received?.totalSamplesReceived ?? '?'}`)
|
||||
audioStats.value = lines
|
||||
}
|
||||
|
||||
function disconnect() {
|
||||
if (audioStatsTimer) clearInterval(audioStatsTimer)
|
||||
audioStatsTimer = null
|
||||
if (levelTimer) clearInterval(levelTimer)
|
||||
levelTimer = null
|
||||
sourceLevel.value = 0
|
||||
roomLevel.value = 0
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({action: 'leave'}))
|
||||
socket.close()
|
||||
} else {
|
||||
socket?.close()
|
||||
}
|
||||
socket = null
|
||||
peer?.close()
|
||||
peer = null
|
||||
microphone?.getTracks().forEach(track => track.stop())
|
||||
microphone = null
|
||||
fileAudio?.pause()
|
||||
fileAudio = null
|
||||
fileStream?.getTracks().forEach(track => track.stop())
|
||||
fileStream = null
|
||||
if (fileUrl) URL.revokeObjectURL(fileUrl)
|
||||
fileUrl = null
|
||||
microphoneAnalyser = null
|
||||
if (microphoneContext) void microphoneContext.close()
|
||||
microphoneContext = null
|
||||
roomAnalysers.forEach(analyser => analyser.disconnect())
|
||||
roomAnalysers.clear()
|
||||
if (roomContext) void roomContext.close()
|
||||
roomContext = null
|
||||
for (const track of players.keys()) removeRoomTrack(track)
|
||||
roomStream = null
|
||||
sourceTracks.clear()
|
||||
pendingCandidates = []
|
||||
connected.value = false
|
||||
}
|
||||
|
||||
async function connect() {
|
||||
const channel = channelId.value.trim()
|
||||
if (!/^[0-9a-fA-F-]{36}$/.test(channel)) {
|
||||
log('Renseigner un identifiant de canal valide.')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
audioStats.value = []
|
||||
let stream: MediaStream
|
||||
if (audioFile.value) {
|
||||
const context = new AudioContext()
|
||||
microphoneContext = context
|
||||
fileUrl = URL.createObjectURL(audioFile.value)
|
||||
const player = new Audio(fileUrl)
|
||||
fileAudio = player
|
||||
player.loop = true
|
||||
const source = context.createMediaElementSource(player)
|
||||
const destination = context.createMediaStreamDestination()
|
||||
const analyser = context.createAnalyser()
|
||||
analyser.fftSize = 2048
|
||||
source.connect(destination)
|
||||
source.connect(analyser)
|
||||
microphoneAnalyser = analyser
|
||||
await context.resume()
|
||||
stream = destination.stream
|
||||
fileStream = stream
|
||||
await player.play()
|
||||
log(`Fichier audio envoyé : ${audioFile.value.name} (en boucle, sans lecture locale)`)
|
||||
} else {
|
||||
stream = await navigator.mediaDevices.getUserMedia({audio: true})
|
||||
microphone = stream
|
||||
try {
|
||||
const context = new AudioContext()
|
||||
microphoneContext = context
|
||||
const analyser = context.createAnalyser()
|
||||
analyser.fftSize = 2048
|
||||
context.createMediaStreamSource(stream).connect(analyser)
|
||||
microphoneAnalyser = analyser
|
||||
await context.resume()
|
||||
} catch (error) {
|
||||
log(`Mesure du niveau micro indisponible : ${String(error)}`)
|
||||
}
|
||||
}
|
||||
const url = new URL(`/ws/rtc/${encodeURIComponent(channel)}`, window.location.href)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const connection = new RTCPeerConnection()
|
||||
const ws = new WebSocket(url)
|
||||
peer = connection
|
||||
socket = ws
|
||||
connected.value = true
|
||||
stream.getAudioTracks().forEach(track => connection.addTrack(track, stream))
|
||||
roomStream = new MediaStream()
|
||||
connection.ontrack = ({streams, track}) => {
|
||||
if (peer !== connection || players.has(track)) return
|
||||
const remoteStream = roomStream!
|
||||
remoteStream.addTrack(track)
|
||||
if (streams[0]) sourceTracks.set(streams[0].id, track)
|
||||
track.onended = () => removeRoomTrack(track)
|
||||
if (remoteAudio.value) {
|
||||
const player = new Audio()
|
||||
player.autoplay = true
|
||||
player.controls = true
|
||||
player.srcObject = new MediaStream([track])
|
||||
players.set(track, player)
|
||||
remoteAudio.value.append(player)
|
||||
void player.play().catch(error => log(`Lecture audio impossible : ${String(error)}`))
|
||||
}
|
||||
if (track.kind === 'audio') {
|
||||
if (!roomContext) {
|
||||
roomContext = new AudioContext()
|
||||
void roomContext.resume().catch(error => log(`Mesure du retour indisponible : ${String(error)}`))
|
||||
}
|
||||
const analyser = roomContext.createAnalyser()
|
||||
analyser.fftSize = 2048
|
||||
roomContext.createMediaStreamSource(new MediaStream([track])).connect(analyser)
|
||||
roomAnalysers.set(track, analyser)
|
||||
}
|
||||
log('Piste audio distante reçue')
|
||||
}
|
||||
connection.onicecandidate = ({candidate}) => {
|
||||
if (candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate}))
|
||||
log('Candidat ICE local envoyé')
|
||||
}
|
||||
}
|
||||
connection.oniceconnectionstatechange = () => {
|
||||
log(`ICE : ${connection.iceConnectionState}`)
|
||||
if (connection.iceConnectionState === 'connected' || connection.iceConnectionState === 'completed') {
|
||||
void logSelectedIcePair(connection).catch(error => log(`Statistiques ICE indisponibles : ${String(error)}`))
|
||||
}
|
||||
}
|
||||
connection.onconnectionstatechange = () => {
|
||||
log(`Connexion : ${connection.connectionState}`)
|
||||
if (connection.connectionState === 'connected' && !audioStatsTimer) {
|
||||
levelTimer = setInterval(updateLevels, 100)
|
||||
void logAudioStats(connection).catch(error => log(`Statistiques audio indisponibles : ${String(error)}`))
|
||||
audioStatsTimer = setInterval(() => {
|
||||
void logAudioStats(connection).catch(error => log(`Statistiques audio indisponibles : ${String(error)}`))
|
||||
}, 2000)
|
||||
} else if (connection.connectionState !== 'connected' && audioStatsTimer) {
|
||||
clearInterval(audioStatsTimer)
|
||||
audioStatsTimer = null
|
||||
if (levelTimer) clearInterval(levelTimer)
|
||||
levelTimer = null
|
||||
sourceLevel.value = 0
|
||||
roomLevel.value = 0
|
||||
}
|
||||
}
|
||||
ws.onopen = async () => {
|
||||
log('WebSocket ouvert')
|
||||
try {
|
||||
const offer = await connection.createOffer()
|
||||
await connection.setLocalDescription(offer)
|
||||
if (ws.readyState !== WebSocket.OPEN) return
|
||||
ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp}))
|
||||
log('Offre SDP envoyée')
|
||||
} catch (error) {
|
||||
log(`Offre impossible : ${String(error)}`)
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
let messageQueue = Promise.resolve()
|
||||
ws.onmessage = ({data}) => {
|
||||
messageQueue = messageQueue.then(async () => {
|
||||
if (socket !== ws) return
|
||||
try {
|
||||
const message = JSON.parse(data)
|
||||
if (message.action === 'answer') {
|
||||
await connection.setRemoteDescription({type: 'answer', sdp: message.sdp})
|
||||
log('Réponse SDP appliquée')
|
||||
for (const candidate of pendingCandidates) {
|
||||
await connection.addIceCandidate({candidate, sdpMLineIndex: 0})
|
||||
}
|
||||
pendingCandidates = []
|
||||
} else if (message.action === 'sdp-offer') {
|
||||
await connection.setRemoteDescription({type: 'offer', sdp: message.sdp})
|
||||
const answer = await connection.createAnswer()
|
||||
await connection.setLocalDescription(answer)
|
||||
ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp}))
|
||||
log('Pistes du salon renégociées')
|
||||
} else if (message.action === 'source-left') {
|
||||
const track = sourceTracks.get(message.id)
|
||||
if (track) {
|
||||
removeRoomTrack(track)
|
||||
sourceTracks.delete(message.id)
|
||||
}
|
||||
log(`Participant parti : ${message.id}`)
|
||||
} else if (message.action === 'ice-candidate') {
|
||||
if (connection.remoteDescription) {
|
||||
await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0})
|
||||
} else {
|
||||
pendingCandidates.push(message.candidate)
|
||||
}
|
||||
log(`Candidat ICE distant reçu : ${message.candidate}`)
|
||||
} else if (message.action === 'error') {
|
||||
log(`Erreur serveur : ${message.message}`)
|
||||
} else {
|
||||
log(`Message inconnu : ${message.action}`)
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Message impossible à traiter : ${String(error)}`)
|
||||
}
|
||||
})
|
||||
}
|
||||
ws.onerror = () => log('Erreur WebSocket (vérifier le serveur, le canal et la session).')
|
||||
ws.onclose = ({code}) => {
|
||||
log(`WebSocket fermé (${code})`)
|
||||
if (socket === ws) disconnect()
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Connexion impossible : ${String(error)}`)
|
||||
disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(disconnect)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-main class="rtc-test pa-6">
|
||||
<div class="rtc-test-content">
|
||||
<h1 class="text-h5 mb-4">Test RTC</h1>
|
||||
<!-- <p class="mb-4">Ouvre cette page dans deux onglets avec le même canal. Chaque onglet entend l'autre, sans retour de son propre micro ou fichier audio.</p>-->
|
||||
<v-text-field v-model="channelId" :disabled="connected" label="Identifiant du canal"/>
|
||||
<input :disabled="connected" accept="audio/*" class="d-block mb-4" type="file"
|
||||
@change="audioFile = ($event.target as HTMLInputElement).files?.[0] ?? null"/>
|
||||
<v-btn v-if="!connected" color="primary" @click="connect">Connecter</v-btn>
|
||||
<v-btn v-else color="primary" @click="disconnect">Quitter</v-btn>
|
||||
<div ref="remoteAudio" class="mt-4"/>
|
||||
<div class="mt-4 rtc-test-panel">
|
||||
<h2 class="text-h6">Niveaux audio</h2>
|
||||
<label for="source-level">Ma source (micro ou fichier)</label>
|
||||
<progress id="source-level" :value="sourceLevel" class="rtc-test-level" max="1"/>
|
||||
<label for="room-level">Son reçu du salon (autres participants)</label>
|
||||
<progress id="room-level" :value="roomLevel" class="rtc-test-level" max="1"/>
|
||||
</div>
|
||||
<div class="mt-4 rtc-test-panel">
|
||||
<h2 class="text-h6">Statistiques WebRTC</h2>
|
||||
<pre class="rtc-test-text">{{ audioStats.join('\n') || 'En attente de connexion…' }}</pre>
|
||||
</div>
|
||||
<div class="mt-4 rtc-test-panel">
|
||||
<h2 class="text-h6">Journal</h2>
|
||||
<pre class="rtc-test-text rtc-test-logs">{{ logs.join('\n') }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</v-main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.rtc-test-content {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.rtc-test-panel {
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(128, 128, 128, 0.5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.rtc-test-level {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 20px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.rtc-test-text {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rtc-test-logs {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
@@ -14,7 +14,8 @@ import type {Channel} from '@/stores/channel'
|
||||
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
|
||||
import {onReloadAll} from '@/plugins/events.ts'
|
||||
import {onGatewayEvent, onReloadAll} from '@/plugins/events.ts'
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
const props = defineProps<{
|
||||
serverId: string
|
||||
@@ -30,6 +31,11 @@ const serverStore = useServerStore()
|
||||
const {currentTree} = storeToRefs(serverStore)
|
||||
const {openContextMenu} = useContextMenu()
|
||||
const authStore = useAuthStore()
|
||||
const voiceStore = useVoiceStore()
|
||||
const voiceChannels = computed<Channel[]>(() => currentTree.value.flatMap(item =>
|
||||
'Category' in item ? item.Category[1].filter((channel: Channel) => channel.channel_type === 'voice') :
|
||||
'Channel' in item && item.Channel.channel_type === 'voice' ? [item.Channel] : [],
|
||||
))
|
||||
const showPermissionsDialog = ref(false)
|
||||
const selectedChannel = ref<any | null>(null)
|
||||
const channelToEdit = ref<Channel | null>(null)
|
||||
@@ -37,6 +43,19 @@ const showEditChannelDialog = ref(false)
|
||||
const showServerSettings = ref(false)
|
||||
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur')
|
||||
|
||||
function selectChannel(channel: Channel) {
|
||||
if (channel.channel_type === 'voice') {
|
||||
void voiceStore.join(props.serverId, channel.id)
|
||||
if (route.params.channelId) void router.push(`/server/${props.serverId}`)
|
||||
}
|
||||
else void router.push(`/server/${props.serverId}/channel/${channel.id}`)
|
||||
}
|
||||
|
||||
watch([() => props.channelId, voiceChannels], () => {
|
||||
const channel = voiceChannels.value.find(item => item.id === props.channelId)
|
||||
if (channel) selectChannel(channel)
|
||||
})
|
||||
|
||||
|
||||
const loadServerData = async (targetServerId: string) => {
|
||||
if (!targetServerId) return
|
||||
@@ -55,13 +74,27 @@ const loadServerData = async (targetServerId: string) => {
|
||||
}
|
||||
|
||||
let stopReloadAll: (() => void) | null = null
|
||||
let stopVoicePresence: (() => void) | null = null
|
||||
|
||||
onMounted(() => {
|
||||
stopReloadAll = onReloadAll(() => loadServerData(props.serverId))
|
||||
stopVoicePresence = onGatewayEvent('VoicePresence', ({action, content}) => {
|
||||
const {server_id, channel_id, user} = content ?? {}
|
||||
if (server_id !== props.serverId || !user?.id) return
|
||||
const channel = voiceChannels.value.find(item => item.id === channel_id)
|
||||
if (!channel) return
|
||||
const participants = channel.voice_participants ?? []
|
||||
if (action === 'joined' && !participants.some(participant => participant.id === user.id)) {
|
||||
channel.voice_participants = [...participants, user].sort((a, b) => a.username.localeCompare(b.username))
|
||||
} else if (action === 'left') {
|
||||
channel.voice_participants = participants.filter(participant => participant.id !== user.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopReloadAll?.()
|
||||
stopVoicePresence?.()
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -286,17 +319,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-for="channel in item.Category[1]" :key="channel.id">
|
||||
<v-list-item
|
||||
v-for="channel in item.Category[1]"
|
||||
:key="channel.id"
|
||||
:title="channel.name"
|
||||
:to="`/server/${serverId}/channel/${channel.id}`"
|
||||
:active="voiceStore.channelId === channel.id"
|
||||
:class="{
|
||||
'font-weight-bold': (channel.unread_count ?? 0) > 0,
|
||||
'server-item-drop-before': isDropTarget(channel.id, 'before'),
|
||||
'server-item-drop-after': isDropTarget(channel.id, 'after'),
|
||||
}"
|
||||
link
|
||||
@click="selectChannel(channel)"
|
||||
draggable="true"
|
||||
@dragstart="startDragging($event, channel.id, 'channel')"
|
||||
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
|
||||
@@ -304,6 +337,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
@dragend="stopDragging"
|
||||
@contextmenu="onChannelContextMenu($event, channel)"
|
||||
>
|
||||
<template #prepend><v-icon :icon="channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="(channel.unread_count ?? 0) > 0"
|
||||
@@ -316,20 +350,27 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<div v-if="channel.channel_type === 'voice'" class="voice-participants">
|
||||
<div v-for="participant in channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
|
||||
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</v-list-group>
|
||||
|
||||
<!-- Canal orphelin (racine) -->
|
||||
<template v-else-if="'Channel' in item">
|
||||
<v-list-item
|
||||
v-else-if="'Channel' in item"
|
||||
:key="item.Channel.id"
|
||||
:title="item.Channel.name"
|
||||
:to="`/server/${serverId}/channel/${item.Channel.id}`"
|
||||
:active="voiceStore.channelId === item.Channel.id"
|
||||
:class="{
|
||||
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
|
||||
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
|
||||
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
|
||||
}"
|
||||
link
|
||||
@click="selectChannel(item.Channel)"
|
||||
draggable="true"
|
||||
@dragstart="startDragging($event, item.Channel.id, 'channel')"
|
||||
@dragover="dragOver($event, item.Channel.id, 'channel', null)"
|
||||
@@ -337,6 +378,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
@dragend="stopDragging"
|
||||
@contextmenu="onChannelContextMenu($event, item.Channel)"
|
||||
>
|
||||
<template #prepend><v-icon :icon="item.Channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
|
||||
<template #append>
|
||||
<v-chip
|
||||
v-if="(item.Channel.unread_count ?? 0) > 0"
|
||||
@@ -349,9 +391,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</v-chip>
|
||||
</template>
|
||||
</v-list-item>
|
||||
<div v-if="item.Channel.channel_type === 'voice'" class="voice-participants">
|
||||
<div v-for="participant in item.Channel.voice_participants ?? []" :key="participant.id" class="text-body-2 py-1">
|
||||
<v-icon size="small" icon="mdi-account-circle-outline" class="mr-2" />{{ participant.username }}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
</v-list>
|
||||
|
||||
|
||||
</v-navigation-drawer>
|
||||
|
||||
<CreateChannelDialog
|
||||
@@ -391,6 +440,11 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.voice-participants {
|
||||
padding-left: 48px;
|
||||
color: rgb(var(--v-theme-on-surface));
|
||||
opacity: 0.75;
|
||||
}
|
||||
.server-item-drop-before {
|
||||
border-top: 2px solid rgb(var(--v-theme-primary));
|
||||
}
|
||||
|
||||
@@ -60,6 +60,11 @@ const router = createRouter({
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'rtc-test',
|
||||
name: 'rtc-test',
|
||||
component: () => import('@/pages/rtc-test.vue'),
|
||||
},
|
||||
{
|
||||
path: 'server/:serverId(default|[0-9a-fA-F-]{36})',
|
||||
name: 'server-dashboard',
|
||||
|
||||
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
|
||||
|
||||
export const useAppStore = defineStore('app', {
|
||||
state: () => ({
|
||||
baseurl: 'http://localhost:8080',
|
||||
baseurl: '',
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {defineStore} from 'pinia'
|
||||
import {useApi} from "@/composables/useApi";
|
||||
import {useVoiceStore} from '@/stores/voice'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
@@ -55,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
|
||||
async logout() {
|
||||
useVoiceStore().leave()
|
||||
const api = useApi()
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface Channel {
|
||||
id: string
|
||||
name?: string
|
||||
channel_type: string
|
||||
voice_participants?: {id: string; username: string}[]
|
||||
server_id?: string | null
|
||||
category_id?: string | null
|
||||
created_at: string
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import {defineStore} from 'pinia'
|
||||
|
||||
export const useVoiceStore = defineStore('voice', {
|
||||
state: () => ({
|
||||
channelId: null as string | null,
|
||||
serverId: null as string | null,
|
||||
connecting: false,
|
||||
error: null as string | null,
|
||||
}),
|
||||
actions: {
|
||||
async join(serverId: string, channelId: string) {
|
||||
if (this.channelId === channelId) return
|
||||
this.leave()
|
||||
this.connecting = true
|
||||
this.error = null
|
||||
const generation = ++voiceGeneration
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({audio: true})
|
||||
if (generation !== voiceGeneration) {
|
||||
stream.getTracks().forEach(track => track.stop())
|
||||
return
|
||||
}
|
||||
microphone = stream
|
||||
const connection = new RTCPeerConnection()
|
||||
peer = connection
|
||||
stream.getAudioTracks().forEach(track => connection.addTrack(track, stream))
|
||||
const url = new URL(`/ws/rtc/${encodeURIComponent(channelId)}`, window.location.href)
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(url)
|
||||
socket = ws
|
||||
this.channelId = channelId
|
||||
this.serverId = serverId
|
||||
const pendingCandidates: string[] = []
|
||||
const tracks = new Map<string, MediaStreamTrack>()
|
||||
connection.ontrack = ({track, streams}) => {
|
||||
if (peer !== connection) return
|
||||
const audio = new Audio()
|
||||
audio.autoplay = true
|
||||
audio.srcObject = new MediaStream([track])
|
||||
audioElements.set(track, audio)
|
||||
if (streams[0]) tracks.set(streams[0].id, track)
|
||||
track.onended = () => removeAudio(track)
|
||||
void audio.play().catch(() => { this.error = 'Lecture du son distant impossible.' })
|
||||
}
|
||||
connection.onicecandidate = ({candidate}) => {
|
||||
if (candidate && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({action: 'ice-candidate', candidate: candidate.candidate}))
|
||||
}
|
||||
}
|
||||
ws.onopen = async () => {
|
||||
try {
|
||||
const offer = await connection.createOffer()
|
||||
await connection.setLocalDescription(offer)
|
||||
if (socket === ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({action: 'sdp-offer', sdp: connection.localDescription?.sdp}))
|
||||
}
|
||||
} catch (error) {
|
||||
if (socket === ws) { this.error = String(error); this.leave() }
|
||||
}
|
||||
}
|
||||
let queue = Promise.resolve()
|
||||
ws.onmessage = ({data}) => {
|
||||
queue = queue.then(async () => {
|
||||
if (socket !== ws) return
|
||||
const message = JSON.parse(data)
|
||||
if (message.action === 'answer') {
|
||||
await connection.setRemoteDescription({type: 'answer', sdp: message.sdp})
|
||||
for (const candidate of pendingCandidates) await connection.addIceCandidate({candidate, sdpMLineIndex: 0})
|
||||
pendingCandidates.length = 0
|
||||
} else if (message.action === 'sdp-offer') {
|
||||
await connection.setRemoteDescription({type: 'offer', sdp: message.sdp})
|
||||
const answer = await connection.createAnswer()
|
||||
await connection.setLocalDescription(answer)
|
||||
ws.send(JSON.stringify({action: 'sdp-answer', sdp: connection.localDescription?.sdp}))
|
||||
} else if (message.action === 'ice-candidate') {
|
||||
if (connection.remoteDescription) await connection.addIceCandidate({candidate: message.candidate, sdpMLineIndex: 0})
|
||||
else pendingCandidates.push(message.candidate)
|
||||
} else if (message.action === 'source-left') {
|
||||
const track = tracks.get(message.id)
|
||||
if (track) removeAudio(track)
|
||||
tracks.delete(message.id)
|
||||
} else if (message.action === 'error') {
|
||||
this.error = message.message
|
||||
this.leave()
|
||||
}
|
||||
}).catch(error => { if (socket === ws) { this.error = String(error); this.leave() } })
|
||||
}
|
||||
ws.onerror = () => { if (socket === ws) this.error = 'Connexion vocale impossible.' }
|
||||
ws.onclose = () => { if (socket === ws) this.leave() }
|
||||
} catch (error) {
|
||||
if (generation === voiceGeneration) this.error = `Microphone indisponible : ${String(error)}`
|
||||
} finally {
|
||||
if (generation === voiceGeneration) this.connecting = false
|
||||
}
|
||||
},
|
||||
leave() {
|
||||
voiceGeneration++
|
||||
if (socket?.readyState === WebSocket.OPEN) socket.send(JSON.stringify({action: 'leave'}))
|
||||
socket?.close()
|
||||
socket = null
|
||||
peer?.close()
|
||||
peer = null
|
||||
microphone?.getTracks().forEach(track => track.stop())
|
||||
microphone = null
|
||||
for (const track of audioElements.keys()) removeAudio(track)
|
||||
this.channelId = null
|
||||
this.serverId = null
|
||||
this.connecting = false
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
let voiceGeneration = 0
|
||||
let socket: WebSocket | null = null
|
||||
let peer: RTCPeerConnection | null = null
|
||||
let microphone: MediaStream | null = null
|
||||
const audioElements = new Map<MediaStreamTrack, HTMLAudioElement>()
|
||||
|
||||
function removeAudio(track: MediaStreamTrack) {
|
||||
const audio = audioElements.get(track)
|
||||
if (!audio) return
|
||||
audio.pause()
|
||||
audio.srcObject = null
|
||||
audioElements.delete(track)
|
||||
}
|
||||
@@ -1,8 +1,21 @@
|
||||
import {readFileSync} from 'node:fs'
|
||||
import {resolve} from 'node:path'
|
||||
import {fileURLToPath, URL} from 'node:url'
|
||||
import Vue from '@vitejs/plugin-vue'
|
||||
import Fonts from 'unplugin-fonts/vite'
|
||||
import {defineConfig} from 'vite'
|
||||
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify'
|
||||
import {parse} from 'smol-toml'
|
||||
|
||||
const projectRoot = fileURLToPath(new URL('..', import.meta.url))
|
||||
const config = parse(readFileSync(resolve(projectRoot, 'config.toml'), 'utf8'))
|
||||
const network = config.network as {tcp_port: number, tls?: {cert_path: string, key_path: string}}
|
||||
const tls = network.tls
|
||||
const https = tls ? {
|
||||
cert: readFileSync(resolve(projectRoot, tls.cert_path)),
|
||||
key: readFileSync(resolve(projectRoot, tls.key_path)),
|
||||
} : undefined
|
||||
const target = `${tls ? 'https' : 'http'}://localhost:${network.tcp_port}`
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
@@ -46,10 +59,19 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 3000,
|
||||
host: '0.0.0.0',
|
||||
https,
|
||||
allowedHosts: ["goesseau.eu"],
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
target,
|
||||
changeOrigin: true,
|
||||
secure: !tls,
|
||||
},
|
||||
'/ws': {
|
||||
target,
|
||||
ws: true,
|
||||
secure: !tls,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -2519,6 +2519,11 @@ sisteransi@^1.0.5:
|
||||
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
|
||||
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
|
||||
|
||||
smol-toml@^1.9.0:
|
||||
version "1.9.0"
|
||||
resolved "https://registry.yarnpkg.com/smol-toml/-/smol-toml-1.9.0.tgz#f36b8dc7eb621c541c48f9fba6925dab95172817"
|
||||
integrity sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==
|
||||
|
||||
"source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2, source-map-js@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
|
||||
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
# Routes HTTP du backend et permissions
|
||||
|
||||
Ce document décrit les routes déclarées dans `src/routes` et leurs contrôles d’accès. Les chemins incluent les préfixes de montage définis dans `src/routes/mod.rs`.
|
||||
|
||||
## Règles générales
|
||||
|
||||
- **Authentification** : les routes sous `/api` listées comme protégées passent par `require_auth`. Un JWT valide doit être transmis comme `Authorization: Bearer <token>`, cookie `token` (ou `jwt` reconnu par le middleware) ou paramètre de requête `token`. L’absence d’identification valide entraîne `401 Unauthorized`.
|
||||
- **Permissions fines** : elles sont évaluées par les handlers à partir des permissions du serveur/canal ou de contrôles d’appartenance. Un utilisateur authentifié qui n’a pas le droit requis reçoit généralement `403 Forbidden`.
|
||||
- **Superuser** : les opérations de gestion globale des utilisateurs l’exigent. Les droits de serveur et de canal s’appliquent par ressource; ne pas déduire qu’un superuser les contourne automatiquement.
|
||||
- **Accès conditionnel** : « membre » signifie appartenir au serveur concerné ou, pour une conversation/canal DM, être autorisé par le contrôle d’appartenance propre à cette ressource.
|
||||
- **WebSocket** : les routes `/ws` sont des upgrades HTTP `GET`; leurs handlers extraient `CurrentUser`, donc elles requièrent aussi une authentification.
|
||||
|
||||
## API (`/api`)
|
||||
|
||||
Les routes ci-dessous, sauf mention contraire, sont protégées par l’authentification globale.
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/auth/login` | Publique; identifiants valides requis pour obtenir une session/cookie. |
|
||||
| `POST` | `/api/auth/bearer-login` | Publique; identifiants valides requis pour obtenir un bearer token. |
|
||||
| `GET` | `/api/auth/me` | JWT valide. |
|
||||
| `POST` | `/api/join` | Publique; inscription soumise à la validation et, selon la configuration, au jeton d’initialisation; un serveur cible avec mot de passe ne peut pas être rejoint par ce flux. |
|
||||
| `GET` | `/api/servers` | JWT; renvoie les serveurs de l’utilisateur. |
|
||||
| `POST` | `/api/servers` | JWT; création d’un serveur. |
|
||||
| `GET` | `/api/servers/{id}` | Membre du serveur. |
|
||||
| `PUT` | `/api/servers/{id}` | `MANAGE_SERVER` sur le serveur. |
|
||||
| `DELETE` | `/api/servers/{id}` | `MANAGE_SERVER` sur le serveur. |
|
||||
| `POST` | `/api/servers/{id}/join` | JWT; logique d’adhésion au serveur (conditions d’accès/mot de passe vérifiées par le handler). |
|
||||
| `GET` | `/api/servers/{server_id}/tree` | Membre du serveur. |
|
||||
| `GET` | `/api/servers/{server_id}/permissions/users` | `MANAGE_SERVER` sur le serveur. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/servers/{server_id}/permissions/users/{user_id}` | `MANAGE_SERVER`; consultation, attribution ou retrait des permissions de l’utilisateur ciblé. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/servers/{server_id}/permissions/roles/{role_id}` | `MANAGE_SERVER`; consultation, attribution ou retrait des permissions du rôle ciblé. |
|
||||
| `PUT` | `/api/server-item-orders/reorder` | `MANAGE_CHANNELS` **et** `MANAGE_CATEGORIES` sur le serveur ciblé. |
|
||||
| `GET` | `/api/categories` | `server_id` requis; membre du serveur. |
|
||||
| `GET` | `/api/categories/{id}` | Membre du serveur associé à la catégorie. |
|
||||
| `POST`, `PUT`, `DELETE` | `/api/categories`, `/api/categories/{id}` | `MANAGE_CATEGORIES` sur le serveur associé. |
|
||||
| `GET` | `/api/channels` | `server_id` requis et appartenance au serveur; seuls les canaux avec `READ_CHANNEL` sont renvoyés. |
|
||||
| `GET` | `/api/channels/{id}` | `READ_CHANNEL` sur le canal. |
|
||||
| `POST` | `/api/channels` | `MANAGE_CHANNELS` sur le serveur. |
|
||||
| `PUT`, `DELETE` | `/api/channels/{id}` | `MANAGE_CHANNELS` sur le serveur du canal. |
|
||||
| `GET` | `/api/channels/{channel_id}/permissions` | `MANAGE_CHANNEL` sur le canal. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/channels/{channel_id}/permissions/users/{user_id}` | `MANAGE_CHANNEL`; lecture, attribution ou retrait de permissions utilisateur. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/channels/{channel_id}/permissions/roles/{role_id}` | `MANAGE_CHANNEL`; lecture, attribution ou retrait de permissions de rôle. |
|
||||
| `GET` | `/api/channels/{channel_id}/read-state` | JWT; état de lecture de l’utilisateur courant pour le canal. |
|
||||
| `PUT` | `/api/channels/{channel_id}/read-state` | JWT; modification de l’état de lecture de l’utilisateur courant pour le canal. |
|
||||
| `GET` | `/api/conversations` | JWT; ne renvoie que les conversations accessibles à l’utilisateur courant. |
|
||||
| `POST` | `/api/conversations` | JWT; création avec les participants fournis. |
|
||||
| `POST` | `/api/conversations/{id}/fork` | JWT et accès/membership à la conversation source. |
|
||||
| `GET` | `/api/roles` | `server_id` requis; membre du serveur. |
|
||||
| `GET` | `/api/roles/{id}` | Membre du serveur du rôle. |
|
||||
| `POST` | `/api/roles` | `MANAGE_ROLES` sur le serveur ciblé. |
|
||||
| `PUT`, `DELETE` | `/api/roles/{id}` | `MANAGE_ROLES` sur le serveur du rôle. |
|
||||
| `GET` | `/api/roles/{id}/members` | Membre du serveur du rôle. |
|
||||
| `PUT`, `DELETE` | `/api/roles/{id}/members/{user_id}` | `MANAGE_ROLES` sur le serveur du rôle. |
|
||||
| `GET` | `/api/messages` | `channel_id` requis et `READ_CHANNEL` sur le canal. |
|
||||
| `POST` | `/api/messages` | `SEND_MESSAGE` sur le canal; les pièces jointes nécessitent également `ATTACH_FILES`. |
|
||||
| `GET` | `/api/messages/{id}` | `READ_CHANNEL` sur le canal du message. |
|
||||
| `PUT` | `/api/messages/{id}` | `EDIT_OWN_MESSAGE` pour son propre message; `EDIT_OTHERS_MESSAGES` pour celui d’un autre utilisateur. |
|
||||
| `DELETE` | `/api/messages/{id}` | `DELETE_OWN_MESSAGE` pour son propre message; `DELETE_OTHERS_MESSAGES` pour celui d’un autre utilisateur. |
|
||||
| `POST` | `/api/messages/{message_id}/reactions` | `ADD_REACTIONS` sur le canal du message. |
|
||||
| `DELETE` | `/api/messages/{message_id}/reactions/{emoji_id}` | `ADD_REACTIONS` sur le canal du message. |
|
||||
| `POST` | `/api/attachments` | `ATTACH_FILES` sur le canal indiqué dans l’upload. |
|
||||
| `GET` | `/api/emojis` | JWT; liste limitée aux emojis accessibles aux serveurs de l’utilisateur. |
|
||||
| `POST` | `/api/emojis` | `MANAGE_SERVER` sur le serveur ciblé. |
|
||||
| `GET` | `/api/emojis/{id}` | Appartenance au serveur associé; emojis globaux selon leur disponibilité. |
|
||||
| `GET` | `/api/emojis/{id}/asset` | Même contrôle d’accès que la lecture de l’emoji. |
|
||||
| `PUT`, `DELETE` | `/api/emojis/{id}` | `MANAGE_SERVER` sur le serveur de l’emoji. |
|
||||
| `GET` | `/api/users` | Superuser uniquement. |
|
||||
| `POST` | `/api/users` | Superuser uniquement. |
|
||||
| `GET`, `PUT`, `DELETE` | `/api/users/{id}` | Superuser uniquement. |
|
||||
|
||||
## Routes avec contrôle d’accès propre au handler
|
||||
|
||||
Ces routes ne sont pas incluses dans le groupe `require_auth` de `/api`; leurs handlers appliquent leur propre authentification et autorisation.
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/attachments/{id}/file` | Authentification obligatoire; `READ_CHANNEL` sur le canal lié à la pièce jointe. |
|
||||
|
||||
## WebSocket (`/ws`)
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `GET` | `/ws/gateway` | JWT valide via `CurrentUser`. |
|
||||
| `GET` | `/ws/voice` | JWT valide via `CurrentUser`; droits vocaux vérifiés ensuite lors des messages/offres liés aux canaux. |
|
||||
| `GET` | `/ws/rtc/{channel_id}` | JWT valide; le canal doit exister, être vocal et l’utilisateur doit être membre du serveur associé. |
|
||||
|
||||
## Documentation et services transverses
|
||||
|
||||
| Méthode | Route | Permission / condition supplémentaire |
|
||||
|---|---|---|
|
||||
| `GET` | `/swagger` et routes d’interface Swagger associées | Interface de documentation générée par Swagger UI; aucun contrôle de permission déclaré dans le routeur. |
|
||||
| `GET` | `/api-docs/openapi.json` | Spécification OpenAPI servie par Swagger UI; aucun contrôle de permission déclaré dans le routeur. |
|
||||
|
||||
## Signification des permissions utilisées
|
||||
|
||||
| Permission | Portée / effet |
|
||||
|---|---|
|
||||
| `MANAGE_SERVER` | Modifier les paramètres du serveur et gérer ses permissions. |
|
||||
| `MANAGE_ROLES` | Créer, modifier, supprimer des rôles et gérer leurs membres. |
|
||||
| `MANAGE_CATEGORIES` | Créer, modifier et supprimer des catégories. |
|
||||
| `MANAGE_CHANNELS` | Créer, modifier et supprimer des canaux. |
|
||||
| `READ_CHANNEL` | Voir le canal et son contenu. |
|
||||
| `SEND_MESSAGE` | Envoyer un message. |
|
||||
| `EDIT_OWN_MESSAGE` / `EDIT_OTHERS_MESSAGES` | Modifier respectivement ses messages / ceux d’autres utilisateurs. |
|
||||
| `DELETE_OWN_MESSAGE` / `DELETE_OTHERS_MESSAGES` | Supprimer respectivement ses messages / ceux d’autres utilisateurs. |
|
||||
| `ADD_REACTIONS` | Ajouter ou retirer une réaction sur un message accessible. |
|
||||
| `ATTACH_FILES` | Ajouter ou télécharger une pièce jointe selon le contrôle appliqué par la route. |
|
||||
| `MANAGE_CHANNEL` | Gérer les permissions d’un canal. |
|
||||
|
||||
Les permissions disponibles dans le domaine comprennent aussi les droits de membres (`KICK_MEMBERS`, `BAN_MEMBERS`, `MANAGE_MEMBERS`, `VIEW_MEMBERS`) et de voix (`JOIN_VOICE`, `SPEAK`, `STREAM`, `MUTE_SELF`, `MUTE_OTHERS`, `MOVE_OTHERS`, `DISCONNECT_OTHERS`, `MANAGE_VOICE_CHANNEL`). Leur présence dans le modèle n’implique pas qu’une route HTTP dédiée existe.
|
||||
@@ -50,6 +50,16 @@ host = "0.0.0.0"
|
||||
tcp_port = 8080
|
||||
# WebRTC ICE/Media UDP multiplexing port
|
||||
udp_port = 8080
|
||||
# Public IPv4 address advertised by ICE when behind NAT (forward udp_port to this server).
|
||||
# external_ip = "203.0.113.1"
|
||||
# STUN servers used when external_ip is absent; set [] to disable discovery.
|
||||
# stun_servers = ["stun:stun.l.google.com:19302"]
|
||||
|
||||
# Optional native HTTPS (omit this section to keep plain HTTP behind a proxy).
|
||||
# [network.tls]
|
||||
# cert_path = "certs/server.pem"
|
||||
# key_path = "certs/server-key.pem"
|
||||
# names = ["localhost", "127.0.0.1"]
|
||||
|
||||
[database]
|
||||
# DSN for database
|
||||
@@ -103,6 +113,23 @@ pub struct NetworkConfig {
|
||||
pub hostv6: Option<Ipv6Addr>,
|
||||
pub tcp_port: u16,
|
||||
pub udp_port: u16,
|
||||
#[serde(default)]
|
||||
pub external_ip: Option<Ipv4Addr>,
|
||||
#[serde(default = "default_stun_servers")]
|
||||
pub stun_servers: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tls: Option<TlsConfig>,
|
||||
}
|
||||
|
||||
fn default_stun_servers() -> Vec<String> {
|
||||
vec!["stun:stun.l.google.com:19302".to_string()]
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TlsConfig {
|
||||
pub cert_path: std::path::PathBuf,
|
||||
pub key_path: std::path::PathBuf,
|
||||
pub names: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Deserialize)]
|
||||
@@ -155,3 +182,38 @@ impl AppConfig {
|
||||
Self::load()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn external_ip_is_optional_and_accepts_public_ipv4() {
|
||||
let default: AppConfig = toml::from_str(DEFAULT_CONFIG_TOML).unwrap();
|
||||
assert_eq!(default.network.external_ip, None);
|
||||
assert_eq!(default.network.stun_servers, vec!["stun:stun.l.google.com:19302"]);
|
||||
|
||||
let configured = DEFAULT_CONFIG_TOML.replace(
|
||||
"# external_ip = \"203.0.113.1\"",
|
||||
"external_ip = \"203.0.113.1\"",
|
||||
);
|
||||
let config: AppConfig = toml::from_str(&configured).unwrap();
|
||||
assert_eq!(config.network.external_ip, Some(Ipv4Addr::new(203, 0, 113, 1)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stun_servers_can_be_disabled_or_replaced() {
|
||||
for (value, expected) in [
|
||||
("[]", vec![]),
|
||||
("[\"stun:example.org:3478\"]", vec!["stun:example.org:3478"]),
|
||||
] {
|
||||
let text = DEFAULT_CONFIG_TOML.replace(
|
||||
"# stun_servers = [\"stun:stun.l.google.com:19302\"]",
|
||||
&format!("stun_servers = {value}"),
|
||||
);
|
||||
let config: AppConfig = toml::from_str(&text).unwrap();
|
||||
assert_eq!(config.network.stun_servers, expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -83,12 +83,15 @@ impl App {
|
||||
))
|
||||
.start(event_bus.clone());
|
||||
|
||||
let rtc = Arc::new(RTCManager::new(
|
||||
&config.network,
|
||||
repositories.clone(),
|
||||
services.clone(),
|
||||
event_bus.clone(),
|
||||
));
|
||||
let rtc = Arc::new(
|
||||
RTCManager::new(
|
||||
&config.network,
|
||||
repositories.clone(),
|
||||
services.clone(),
|
||||
event_bus.clone(),
|
||||
)
|
||||
.await,
|
||||
);
|
||||
|
||||
let state = AppState {
|
||||
db,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::models::channel::ChannelType;
|
||||
use crate::rtc::VoiceParticipant;
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
@@ -49,6 +50,8 @@ pub struct ChannelResponse {
|
||||
/// Some(value) : valeur de computed_permission (0 si absente).
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub permission: Option<u64>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub voice_participants: Option<Vec<VoiceParticipant>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::models::category;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CategoryCreatedEvent {
|
||||
pub category: category::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CategoryUpdatedEvent {
|
||||
pub category: category::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CategoryDeletedEvent {
|
||||
pub category: category::Model,
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
use crate::models::{channel_role_permission, channel_user_permission};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelUserPermissionCreatedEvent {
|
||||
pub permission: channel_user_permission::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelUserPermissionUpdatedEvent {
|
||||
pub channel_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub permission: Option<channel_user_permission::Model>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelUserPermissionDeletedEvent {
|
||||
pub permission: channel_user_permission::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ChannelRolePermissionUpdatedEvent {
|
||||
pub channel_id: Uuid,
|
||||
pub server_id: Uuid,
|
||||
pub role_id: Uuid,
|
||||
pub permission: Option<channel_role_permission::Model>,
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
pub mod category;
|
||||
pub mod channel;
|
||||
pub mod channel_permission;
|
||||
pub mod emoji;
|
||||
pub mod message;
|
||||
pub mod role;
|
||||
pub mod server;
|
||||
pub mod server_permission;
|
||||
pub mod server_tree;
|
||||
pub mod user;
|
||||
pub mod voice_presence;
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
use crate::models::{role, role_user};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleCreatedEvent {
|
||||
pub role: role::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleUpdatedEvent {
|
||||
pub role: role::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleDeletedEvent {
|
||||
pub role: role::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleUserCreatedEvent {
|
||||
pub role_user: role_user::Model,
|
||||
pub server_id: Uuid,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct RoleUserDeletedEvent {
|
||||
pub role_user: role_user::Model,
|
||||
pub server_id: Uuid,
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
use crate::models::server;
|
||||
use crate::models::server_user;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerCreatedEvent {
|
||||
@@ -13,4 +15,15 @@ pub struct ServerUpdatedEvent {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerDeletedEvent {
|
||||
pub server: server::Model,
|
||||
pub audience: Vec<Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerUserCreatedEvent {
|
||||
pub server_user: server_user::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerUserDeletedEvent {
|
||||
pub server_user: server_user::Model,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use crate::models::{server_role_permission, server_user_permission};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerUserPermissionUpdatedEvent {
|
||||
pub server_id: Uuid,
|
||||
pub user_id: Uuid,
|
||||
pub permission: Option<server_user_permission::Model>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServerRolePermissionUpdatedEvent {
|
||||
pub server_id: Uuid,
|
||||
pub role_id: Uuid,
|
||||
pub permission: Option<server_role_permission::Model>,
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
use crate::models::user;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserCreatedEvent {
|
||||
pub user: user::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserUpdatedEvent {
|
||||
pub user: user::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserChangedEvent {
|
||||
pub user: user::Model,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserDeletedEvent {
|
||||
pub user: user::Model,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use crate::rtc::VoiceParticipant;
|
||||
use serde::Serialize;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct VoicePresenceEvent {
|
||||
pub server_id: Uuid,
|
||||
pub channel_id: Uuid,
|
||||
pub user: VoiceParticipant,
|
||||
#[serde(skip)]
|
||||
pub joined: bool,
|
||||
}
|
||||
+5
-3
@@ -23,9 +23,10 @@ pub struct RequestContext {
|
||||
///
|
||||
/// **Usage :**
|
||||
/// ```rust
|
||||
/// pub async fn ma_vue(user: CurrentUser) {
|
||||
/// if user.is_superuser { ... }
|
||||
/// }
|
||||
/// use oxspeak_server_lib::http::context::CurrentUser;
|
||||
/// # fn check(user: CurrentUser) {
|
||||
/// # let _ = user.is_superuser;
|
||||
/// # }
|
||||
/// ```
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CurrentUser(pub user::Model);
|
||||
@@ -71,6 +72,7 @@ where
|
||||
///
|
||||
/// **Usage :**
|
||||
/// ```rust
|
||||
/// use oxspeak_server_lib::http::context::Superuser;
|
||||
/// pub async fn suppression_globale(admin: Superuser) {
|
||||
/// // Ici, nous sommes certains que admin.is_superuser est true.
|
||||
/// }
|
||||
|
||||
+1
-3
@@ -79,9 +79,7 @@ impl IntoResponse for HTTPError {
|
||||
.into_response();
|
||||
}
|
||||
HTTPError::Internal(err) => {
|
||||
// On utilise %err pour un message d'erreur clair sans backtrace brute
|
||||
// mais on garde les détails pour le span tracing si besoin.
|
||||
tracing::error!(%err, "Request error");
|
||||
tracing::error!(error = %format_args!("{err:#}"), "Request error");
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,9 @@ pub mod metrics;
|
||||
pub mod middleware;
|
||||
pub mod permissions;
|
||||
pub mod server;
|
||||
mod tls;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
pub mod validation;
|
||||
|
||||
pub use permissions::{RequireChannelPermission, RequireServerPermission};
|
||||
|
||||
+81
-88
@@ -1,11 +1,11 @@
|
||||
// Unused
|
||||
|
||||
use super::context::CurrentUser;
|
||||
use super::error::HTTPError;
|
||||
use crate::core::AppState;
|
||||
use crate::permissions::{ChannelPermission, ServerPermission};
|
||||
use axum::extract::FromRequestParts;
|
||||
use axum::extract::{FromRequestParts, RawPathParams};
|
||||
use axum::http::request::Parts;
|
||||
use crate::models::{channel_user, role, role_user, server_user, server_role_permission, channel_role_permission};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use std::ops::Deref;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -15,23 +15,21 @@ use uuid::Uuid;
|
||||
/// The target `server_id` is automatically extracted from path parameters (supporting
|
||||
/// path parameters named `server_id` or `id`).
|
||||
///
|
||||
/// # Superuser Bypass
|
||||
/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes.
|
||||
///
|
||||
/// # Usage Example
|
||||
/// ```rust
|
||||
/// use axum::extract::State;
|
||||
/// use axum::extract::{Path, State};
|
||||
/// use uuid::Uuid;
|
||||
/// use crate::http::permissions::RequireServerPermission;
|
||||
/// use crate::permissions::ServerPermission;
|
||||
/// use crate::core::AppState;
|
||||
/// use oxspeak_server_lib::http::permissions::RequireServerPermission;
|
||||
/// use oxspeak_server_lib::http::error::HTTPError;
|
||||
/// use oxspeak_server_lib::permissions::ServerPermission;
|
||||
/// use oxspeak_server_lib::core::AppState;
|
||||
///
|
||||
/// pub async fn update_server_settings(
|
||||
/// RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>(user): RequireServerPermission<{ ServerPermission::MANAGE_SERVER.bits() }>,
|
||||
/// RequireServerPermission::<{ ServerPermission::MANAGE_SERVER.bits() }>(_user): RequireServerPermission<{ ServerPermission::MANAGE_SERVER.bits() }>,
|
||||
/// State(state): State<AppState>,
|
||||
/// Path(server_id): Path<Uuid>,
|
||||
/// Path(_server_id): Path<Uuid>,
|
||||
/// ) -> Result<(), HTTPError> {
|
||||
/// // User has MANAGE_SERVER or is a superuser
|
||||
/// // User has MANAGE_SERVER
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
@@ -46,30 +44,15 @@ impl<const PERM: u64> Deref for RequireServerPermission<PERM> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, const PERM: u64> FromRequestParts<S> for RequireServerPermission<PERM>
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
impl<const PERM: u64> FromRequestParts<AppState> for RequireServerPermission<PERM> {
|
||||
type Rejection = HTTPError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
// 1. Extract CurrentUser (which validates authentication and returns 401 if missing)
|
||||
let current_user = CurrentUser::from_request_parts(parts, state).await?;
|
||||
|
||||
// 2. Superuser bypasses all checks
|
||||
if current_user.is_superuser {
|
||||
return Ok(RequireServerPermission(current_user));
|
||||
}
|
||||
|
||||
// 3. Get AppState from extensions
|
||||
let app_state = match parts.extensions.get::<AppState>() {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
return Err(HTTPError::InternalServerError(
|
||||
"AppState missing in request extensions".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
// 4. Extract server_id from path parameters.
|
||||
let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) {
|
||||
@@ -82,22 +65,7 @@ where
|
||||
};
|
||||
|
||||
// 5. Check user permission via server repository
|
||||
let permission_result = app_state
|
||||
.repositories
|
||||
.server
|
||||
.get_user_permission(server_id, current_user.id)
|
||||
.await;
|
||||
|
||||
let permission_bits = match permission_result {
|
||||
Ok(Some(p)) => p.permissions,
|
||||
Ok(None) => 0,
|
||||
Err(e) => return Err(HTTPError::InternalServerError(e.to_string())),
|
||||
};
|
||||
|
||||
let required = ServerPermission::from_bits_truncate(PERM);
|
||||
let granted = ServerPermission::from_bits_truncate(permission_bits as u64);
|
||||
|
||||
if granted.contains(required) {
|
||||
if check_server_permission(state, current_user.id, server_id, ServerPermission::from_bits_truncate(PERM)).await? {
|
||||
Ok(RequireServerPermission(current_user))
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
@@ -110,23 +78,21 @@ where
|
||||
///
|
||||
/// The target `channel_id` (or `id`) is automatically extracted from path parameters.
|
||||
///
|
||||
/// # Superuser Bypass
|
||||
/// If the user is a superuser (`is_superuser == true`), the permission check automatically passes.
|
||||
///
|
||||
/// # Usage Example
|
||||
/// ```rust
|
||||
/// use axum::extract::State;
|
||||
/// use axum::extract::{Path, State};
|
||||
/// use uuid::Uuid;
|
||||
/// use crate::http::permissions::RequireChannelPermission;
|
||||
/// use crate::permissions::ChannelPermission;
|
||||
/// use crate::core::AppState;
|
||||
/// use oxspeak_server_lib::http::permissions::RequireChannelPermission;
|
||||
/// use oxspeak_server_lib::http::error::HTTPError;
|
||||
/// use oxspeak_server_lib::permissions::ChannelPermission;
|
||||
/// use oxspeak_server_lib::core::AppState;
|
||||
///
|
||||
/// pub async fn read_channel_messages(
|
||||
/// RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>(user): RequireChannelPermission<{ ChannelPermission::READ_CHANNEL.bits() }>,
|
||||
/// RequireChannelPermission::<{ ChannelPermission::READ_CHANNEL.bits() }>(_user): RequireChannelPermission<{ ChannelPermission::READ_CHANNEL.bits() }>,
|
||||
/// State(state): State<AppState>,
|
||||
/// Path(channel_id): Path<Uuid>,
|
||||
/// Path(_channel_id): Path<Uuid>,
|
||||
/// ) -> Result<(), HTTPError> {
|
||||
/// // User has READ_CHANNEL or is a superuser
|
||||
/// // User has READ_CHANNEL
|
||||
/// Ok(())
|
||||
/// }
|
||||
/// ```
|
||||
@@ -141,27 +107,13 @@ impl<const PERM: u64> Deref for RequireChannelPermission<PERM> {
|
||||
}
|
||||
}
|
||||
|
||||
impl<S, const PERM: u64> FromRequestParts<S> for RequireChannelPermission<PERM>
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
impl<const PERM: u64> FromRequestParts<AppState> for RequireChannelPermission<PERM> {
|
||||
type Rejection = HTTPError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
||||
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Self::Rejection> {
|
||||
let current_user = CurrentUser::from_request_parts(parts, state).await?;
|
||||
|
||||
if current_user.is_superuser {
|
||||
return Ok(RequireChannelPermission(current_user));
|
||||
}
|
||||
|
||||
let app_state = match parts.extensions.get::<AppState>() {
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
return Err(HTTPError::InternalServerError(
|
||||
"AppState missing in request extensions".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
let channel_id = match extract_path_param_uuid(parts, &["channel_id", "id"]) {
|
||||
Some(id) => id,
|
||||
@@ -172,22 +124,7 @@ where
|
||||
}
|
||||
};
|
||||
|
||||
let permission_result = app_state
|
||||
.repositories
|
||||
.channel
|
||||
.get_user_permission(channel_id, current_user.id)
|
||||
.await;
|
||||
|
||||
let permission_bits = match permission_result {
|
||||
Ok(Some(p)) => p.permissions,
|
||||
Ok(None) => 0,
|
||||
Err(e) => return Err(HTTPError::InternalServerError(e.to_string())),
|
||||
};
|
||||
|
||||
let required = ChannelPermission::from_bits_truncate(PERM);
|
||||
let granted = ChannelPermission::from_bits_truncate(permission_bits as u64);
|
||||
|
||||
if granted.contains(required) {
|
||||
if check_channel_permission(state, current_user.id, channel_id, ChannelPermission::from_bits_truncate(PERM)).await? {
|
||||
Ok(RequireChannelPermission(current_user))
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
@@ -198,6 +135,13 @@ where
|
||||
/// Helper function to extract a Uuid path parameter matching any of the given key names
|
||||
/// from Axum request extensions.
|
||||
fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
|
||||
if let Some(params) = parts.extensions.get::<RawPathParams>() {
|
||||
for (key, value) in params.iter() {
|
||||
if keys.contains(&key) {
|
||||
if let Ok(id) = Uuid::parse_str(value) { return Some(id); }
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(map) = parts
|
||||
.extensions
|
||||
.get::<std::collections::HashMap<String, String>>()
|
||||
@@ -223,3 +167,52 @@ fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn check_server_permission(state: &AppState, user_id: Uuid, server_id: Uuid, required: ServerPermission) -> Result<bool, HTTPError> {
|
||||
let member = server_user::Entity::find()
|
||||
.filter(server_user::Column::ServerId.eq(server_id))
|
||||
.filter(server_user::Column::UserId.eq(user_id))
|
||||
.one(&state.db).await?;
|
||||
if member.is_none() { return Ok(false); }
|
||||
let mut bits = state.repositories.server.get_user_permission(server_id, user_id).await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?
|
||||
.map_or(0, |p| p.permissions as u64);
|
||||
let roles = role_user::Entity::find().filter(role_user::Column::UserId.eq(user_id)).all(&state.db).await?;
|
||||
for assignment in roles {
|
||||
if role::Entity::find_by_id(assignment.role_id).one(&state.db).await?.is_some_and(|r| r.server_id == server_id) {
|
||||
if let Some(p) = server_role_permission::Entity::find()
|
||||
.filter(server_role_permission::Column::ServerId.eq(server_id))
|
||||
.filter(server_role_permission::Column::RoleId.eq(assignment.role_id))
|
||||
.one(&state.db).await? { bits |= p.permissions as u64; }
|
||||
}
|
||||
}
|
||||
Ok(ServerPermission::from_bits_truncate(bits).contains(required))
|
||||
}
|
||||
|
||||
pub async fn check_channel_permission(state: &AppState, user_id: Uuid, channel_id: Uuid, required: ChannelPermission) -> Result<bool, HTTPError> {
|
||||
let channel = state.repositories.channel.get_by_id(channel_id).await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?;
|
||||
let Some(channel) = channel else { return Ok(false) };
|
||||
if let Some(server_id) = channel.server_id {
|
||||
if !check_server_permission(state, user_id, server_id, ServerPermission::empty()).await? { return Ok(false); }
|
||||
} else if channel_user::Entity::find()
|
||||
.filter(channel_user::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_user::Column::UserId.eq(user_id))
|
||||
.one(&state.db).await?.is_none() { return Ok(false); }
|
||||
let mut bits = if channel.server_id.is_none() { crate::permissions::PermissionSet::DEFAULT.channel.bits() } else { 0 };
|
||||
bits |= state.repositories.channel.get_user_permission(channel_id, user_id).await
|
||||
.map_err(|e| HTTPError::InternalServerError(e.to_string()))?
|
||||
.map_or(0, |p| p.permissions as u64);
|
||||
if let Some(server_id) = channel.server_id {
|
||||
let roles = role_user::Entity::find().filter(role_user::Column::UserId.eq(user_id)).all(&state.db).await?;
|
||||
for assignment in roles {
|
||||
if role::Entity::find_by_id(assignment.role_id).one(&state.db).await?.is_some_and(|r| r.server_id == server_id) {
|
||||
if let Some(p) = channel_role_permission::Entity::find()
|
||||
.filter(channel_role_permission::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_role_permission::Column::RoleId.eq(assignment.role_id))
|
||||
.one(&state.db).await? { bits |= p.permissions as u64; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(ChannelPermission::from_bits_truncate(bits).contains(required))
|
||||
}
|
||||
|
||||
+20
-1
@@ -15,12 +15,13 @@ use tower_http::catch_panic::CatchPanicLayer;
|
||||
use tower_http::cors::CorsLayer;
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::config::NetworkConfig;
|
||||
use crate::config::{NetworkConfig, TlsConfig};
|
||||
use crate::core::AppState;
|
||||
use crate::routes;
|
||||
|
||||
use super::metrics::HttpMetrics;
|
||||
use super::middleware;
|
||||
use super::tls;
|
||||
|
||||
// ── Erreurs ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -66,6 +67,7 @@ pub enum HttpServerError {
|
||||
/// ```
|
||||
pub struct HttpServer {
|
||||
bind_addr: SocketAddr,
|
||||
tls: Option<TlsConfig>,
|
||||
app_state: AppState,
|
||||
metrics: Arc<HttpMetrics>,
|
||||
shutdown_rx: broadcast::Receiver<()>,
|
||||
@@ -87,6 +89,7 @@ impl HttpServer {
|
||||
(
|
||||
Self {
|
||||
bind_addr,
|
||||
tls: network_config.tls.clone(),
|
||||
app_state,
|
||||
metrics,
|
||||
shutdown_rx,
|
||||
@@ -149,6 +152,22 @@ impl HttpServer {
|
||||
// Outermost : intercepte les panics et retourne une 500 propre
|
||||
.layer(CatchPanicLayer::new());
|
||||
|
||||
if let Some(tls_config) = &self.tls {
|
||||
let rustls = tls::load_or_create(tls_config).await?;
|
||||
let handle = axum_server::Handle::new();
|
||||
let shutdown = handle.clone();
|
||||
tokio::spawn(async move {
|
||||
let _ = self.shutdown_rx.recv().await;
|
||||
shutdown.graceful_shutdown(Some(std::time::Duration::from_secs(10)));
|
||||
});
|
||||
tracing::info!(addr = %self.bind_addr, "HTTPS server listening");
|
||||
axum_server::bind_rustls(self.bind_addr, rustls)
|
||||
.handle(handle)
|
||||
.serve(app.into_make_service())
|
||||
.await?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let listener =
|
||||
TcpListener::bind(self.bind_addr)
|
||||
.await
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::config::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use crate::core::{App, AppState};
|
||||
use crate::http::context::{CurrentUser, RequestContext};
|
||||
use crate::models::user;
|
||||
use axum::body::Body;
|
||||
use axum::http::{Method, Request};
|
||||
use chrono::Utc;
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use std::time::Instant;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub async fn state() -> AppState {
|
||||
let path = format!("{}/target/permissions-{}.db", env!("CARGO_MANIFEST_DIR"), Uuid::new_v4());
|
||||
let config: AppConfig = toml::from_str(&DEFAULT_CONFIG_TOML.replace(
|
||||
"sqlite://oxspeak.db",
|
||||
&format!("sqlite://{path}"),
|
||||
))
|
||||
.unwrap();
|
||||
App::build(config).await.unwrap().state
|
||||
}
|
||||
|
||||
pub async fn user(state: &AppState, admin: bool) -> user::Model {
|
||||
user::ActiveModel {
|
||||
username: Set(format!("test-{}", Uuid::new_v4())),
|
||||
password: Set("unused".into()),
|
||||
created_at: Set(Utc::now()),
|
||||
updated_at: Set(Utc::now()),
|
||||
is_superuser: Set(admin),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
pub fn request(method: Method, uri: &str, body: Body, user: Option<user::Model>) -> Request<Body> {
|
||||
let mut request = Request::builder().method(method).uri(uri).body(body).unwrap();
|
||||
let method = request.method().clone();
|
||||
let uri = request.uri().clone();
|
||||
request.extensions_mut().insert(RequestContext {
|
||||
request_id: Uuid::new_v4(),
|
||||
started_at: Instant::now(),
|
||||
method,
|
||||
uri,
|
||||
user: user.map(CurrentUser),
|
||||
});
|
||||
request
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::config::TlsConfig;
|
||||
use axum_server::tls_rustls::RustlsConfig;
|
||||
use std::io;
|
||||
|
||||
pub async fn load_or_create(config: &TlsConfig) -> io::Result<RustlsConfig> {
|
||||
if config.names.is_empty() || config.names.iter().any(|name| name.is_empty()) {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "TLS requires at least one non-empty name"));
|
||||
}
|
||||
if config.cert_path == config.key_path {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidInput, "certificate and key paths must differ"));
|
||||
}
|
||||
|
||||
let cert_exists = config.cert_path.exists();
|
||||
let key_exists = config.key_path.exists();
|
||||
if cert_exists != key_exists {
|
||||
return Err(io::Error::new(io::ErrorKind::InvalidData, "only one TLS file exists; restore the missing file or remove both to regenerate"));
|
||||
}
|
||||
if !cert_exists {
|
||||
let generated = rcgen::generate_simple_self_signed(config.names.clone())
|
||||
.map_err(io::Error::other)?;
|
||||
for path in [&config.cert_path, &config.key_path] {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
}
|
||||
std::fs::write(&config.cert_path, generated.cert.pem())?;
|
||||
let key = generated.key_pair.serialize_pem();
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::io::Write;
|
||||
let mut file = std::fs::OpenOptions::new().write(true).create_new(true).mode(0o600).open(&config.key_path)?;
|
||||
file.write_all(key.as_bytes())?;
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
std::fs::write(&config.key_path, key)?;
|
||||
tracing::info!(cert = %config.cert_path.display(), "Generated self-signed TLS certificate");
|
||||
}
|
||||
RustlsConfig::from_pem_file(&config.cert_path, &config.key_path).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_is_reused_and_partial_pair_is_rejected() {
|
||||
let dir = std::env::temp_dir().join(format!("oxspeak-tls-{}", uuid::Uuid::new_v4()));
|
||||
let config = TlsConfig {
|
||||
cert_path: dir.join("cert.pem"), key_path: dir.join("key.pem"),
|
||||
names: vec!["localhost".into()],
|
||||
};
|
||||
load_or_create(&config).await.unwrap();
|
||||
let cert = std::fs::read(&config.cert_path).unwrap();
|
||||
let key = std::fs::read(&config.key_path).unwrap();
|
||||
load_or_create(&config).await.unwrap();
|
||||
assert_eq!(cert, std::fs::read(&config.cert_path).unwrap());
|
||||
assert_eq!(key, std::fs::read(&config.key_path).unwrap());
|
||||
std::fs::remove_file(&config.key_path).unwrap();
|
||||
assert!(load_or_create(&config).await.is_err());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,9 @@ use crate::domain::dto::attachment::AttachmentUploadResponse;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::attachment;
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::routes::attachment::mapper;
|
||||
use crate::routes::message::handlers::can_access;
|
||||
use crate::routes::message::handlers::require_channel_permission;
|
||||
use crate::services::media::{self, PendingMediaFile};
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Multipart, Path, State};
|
||||
@@ -75,9 +76,14 @@ pub async fn create(
|
||||
.content_type()
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
if !can_access(&state, channel, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
channel,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::ATTACH_FILES,
|
||||
)
|
||||
.await?;
|
||||
let id = Uuid::new_v4();
|
||||
let mut output = PendingMediaFile::begin(
|
||||
PathBuf::from(&state.config.media.root).as_path(),
|
||||
@@ -128,9 +134,14 @@ pub async fn create(
|
||||
}
|
||||
let channel_id =
|
||||
channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
|
||||
if !can_access(&state, channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::ATTACH_FILES,
|
||||
)
|
||||
.await?;
|
||||
if created.is_empty() {
|
||||
return Err(HTTPError::BadRequest(
|
||||
"at least one file is required".into(),
|
||||
@@ -145,6 +156,7 @@ pub async fn create(
|
||||
}
|
||||
|
||||
pub async fn file(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, HTTPError> {
|
||||
@@ -152,6 +164,14 @@ pub async fn file(
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_channel_permission(
|
||||
&state,
|
||||
item.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(&item.file_path))
|
||||
.await
|
||||
.map_err(|_| HTTPError::NotFound)?;
|
||||
|
||||
@@ -15,3 +15,97 @@ pub fn secure_router() -> Router<AppState> {
|
||||
pub fn public_router() -> Router<AppState> {
|
||||
Router::new().route("/attachments/{id}/file", get(handlers::file))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod permission_tests {
|
||||
use super::*;
|
||||
use crate::models::attachment;
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::routes::message::routes::permission_tests::Fixture;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, StatusCode},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn upload_requires_attach_files() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Le contenu multipart est rejoué avant et après l'octroi du droit.
|
||||
let boundary = "attachment-permission-test";
|
||||
let body = format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"channel_id\"\r\n\r\n{}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"file\"; filename=\"hello.txt\"\r\nContent-Type: text/plain\r\n\r\nhello\r\n--{boundary}--\r\n",
|
||||
fixture.channel_id
|
||||
);
|
||||
let mime = format!("multipart/form-data; boundary={boundary}");
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
secure_router(),
|
||||
Method::POST,
|
||||
"/attachments",
|
||||
Body::from(body.clone()),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::ATTACH_FILES).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
secure_router(),
|
||||
Method::POST,
|
||||
"/attachments",
|
||||
Body::from(body),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
std::fs::remove_dir_all(&fixture.state.config.media.root).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn download_requires_read_channel() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Associe un fichier à un canal pour vérifier que son téléchargement suit READ_CHANNEL.
|
||||
let id = Uuid::new_v4();
|
||||
let path = format!("attachments/{id}.txt");
|
||||
let full_path = std::path::Path::new(&fixture.state.config.media.root).join(&path);
|
||||
tokio::fs::create_dir_all(full_path.parent().unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
tokio::fs::write(&full_path, b"hello").await.unwrap();
|
||||
attachment::ActiveModel {
|
||||
id: Set(id),
|
||||
message_id: Set(None),
|
||||
channel_id: Set(fixture.channel_id),
|
||||
user_id: Set(fixture.user.id),
|
||||
filename: Set("hello.txt".into()),
|
||||
file_size: Set(5),
|
||||
mime_type: Set("text/plain".into()),
|
||||
file_path: Set(path),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/attachments/{id}/file");
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(public_router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::READ_CHANNEL).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(public_router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
std::fs::remove_dir_all(&fixture.state.config.media.root).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,3 +9,25 @@ pub fn router() -> OxRouter {
|
||||
.route("/auth/bearer-login", post(handlers::login_bearer))
|
||||
.route("/auth/me", get(handlers::me))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use axum::{body::Body, http::{Method, StatusCode}};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn login_is_public_and_me_requires_authentication() {
|
||||
let state = state().await;
|
||||
let member = user(&state, false).await;
|
||||
let routes = router().with_state(state);
|
||||
for uri in ["/auth/login", "/auth/bearer-login"] {
|
||||
let mut login = request(Method::POST, uri, Body::from(r#"{"username":"nobody","password":"wrong"}"#), None);
|
||||
login.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(routes.clone().oneshot(login).await.unwrap().status(), StatusCode::UNAUTHORIZED, "{uri}");
|
||||
}
|
||||
assert_eq!(routes.clone().oneshot(request(Method::GET, "/auth/me", Body::empty(), None)).await.unwrap().status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(routes.oneshot(request(Method::GET, "/auth/me", Body::empty(), Some(member))).await.unwrap().status(), StatusCode::OK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,10 @@ use crate::core::state::AppState;
|
||||
use crate::domain::dto::category::{
|
||||
CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
|
||||
};
|
||||
use crate::http::context::Superuser;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use crate::routes::category::mapper;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -26,9 +28,12 @@ use uuid::Uuid;
|
||||
tag = "Categories"
|
||||
)]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<CategoryQueryParams>,
|
||||
) -> Result<Json<Vec<CategoryResponse>>, HTTPError> {
|
||||
let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
let categories = state
|
||||
.repositories
|
||||
.category
|
||||
@@ -57,6 +62,7 @@ pub async fn get_all(
|
||||
tag = "Categories"
|
||||
)]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<CategoryResponse>, HTTPError> {
|
||||
@@ -66,6 +72,7 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
state.repositories.server.get_user(category.server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
|
||||
Ok(Json(mapper::category_model_to_category_response(category)))
|
||||
}
|
||||
@@ -86,7 +93,7 @@ pub async fn get_by_id(
|
||||
)
|
||||
)]
|
||||
pub async fn create(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateCategoryRequest>,
|
||||
) -> Result<(StatusCode, Json<CategoryResponse>), HTTPError> {
|
||||
@@ -97,6 +104,7 @@ pub async fn create(
|
||||
.get_by_id(payload.server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||
require_server_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
|
||||
let category = state
|
||||
.services
|
||||
@@ -128,18 +136,19 @@ pub async fn create(
|
||||
)
|
||||
)]
|
||||
pub async fn update(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateCategoryRequest>,
|
||||
) -> Result<Json<CategoryResponse>, HTTPError> {
|
||||
// Vérifier l'existence
|
||||
let _category = state
|
||||
let category = state
|
||||
.repositories
|
||||
.category
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
|
||||
let category = state
|
||||
.services
|
||||
@@ -168,10 +177,12 @@ pub async fn update(
|
||||
)
|
||||
)]
|
||||
pub async fn delete(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
let category = state.repositories.category.get_by_id(id).await?.ok_or(HTTPError::NotFound)?;
|
||||
require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
if state.services.category.delete_category(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router};
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response {
|
||||
let mut req = request(method, uri, Body::from(body.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
router.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn category_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let list = format!("/categories?server_id={server}");
|
||||
|
||||
// La lecture est réservée aux membres du serveur.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let payload = json!({"server_id":server,"name":"test-category"});
|
||||
|
||||
// Être membre suffit pour lire, mais pas pour gérer les catégories.
|
||||
assert_eq!(
|
||||
call(&router, Method::POST, "/categories", payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CATEGORIES.bits()).await.unwrap();
|
||||
let created = call(&router, Method::POST, "/categories", payload, actor.clone()).await;
|
||||
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let id: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap();
|
||||
let uri = format!("/categories/{}", id["id"].as_str().unwrap());
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
// Le retrait du droit de gestion bloque modification et suppression.
|
||||
state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, json!({"name":"renamed"}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CATEGORIES.bits()).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, json!({"name":"renamed"}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,11 @@ use crate::domain::dto::channel::{
|
||||
ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
|
||||
SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest,
|
||||
};
|
||||
use crate::http::context::{CurrentUser, Superuser};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::{ChannelPermission, ServerPermission};
|
||||
use crate::http::permissions::check_channel_permission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use crate::models::{channel, channel_user};
|
||||
use crate::routes::channel::mapper;
|
||||
use axum::{
|
||||
@@ -16,6 +19,23 @@ use axum::{
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn require_channel_permission(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
channel_id: Uuid,
|
||||
permission: ChannelPermission,
|
||||
) -> Result<channel::Model, HTTPError> {
|
||||
let channel = state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||
if check_channel_permission(state, user.id, channel_id, permission).await? { Ok(channel) } else { Err(HTTPError::Forbidden) }
|
||||
}
|
||||
|
||||
async fn require_channel_manager(state: &AppState, user: &CurrentUser, channel_id: Uuid) -> Result<channel::Model, HTTPError> {
|
||||
let channel = state.repositories.channel.get_by_id(channel_id).await?.ok_or(HTTPError::NotFound)?;
|
||||
let server_id = channel.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
require_server_permission(state, user, server_id, ServerPermission::MANAGE_CHANNELS).await?;
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
async fn require_channel_member(
|
||||
state: &AppState,
|
||||
channel_id: Uuid,
|
||||
@@ -54,16 +74,26 @@ async fn require_channel_member(
|
||||
tag = "Channels"
|
||||
)]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<ChannelQueryParams>,
|
||||
) -> Result<Json<Vec<ChannelResponse>>, HTTPError> {
|
||||
let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
let params = mapper::query_params_to_channel_filter(filters);
|
||||
let channels = state.repositories.channel.filter(params).await?;
|
||||
Ok(Json(
|
||||
channels
|
||||
.into_iter()
|
||||
.map(mapper::channel_model_to_channel_response)
|
||||
.collect(),
|
||||
{
|
||||
let mut visible = Vec::new();
|
||||
for channel in channels {
|
||||
match require_channel_permission(&state, &user, channel.id, ChannelPermission::READ_CHANNEL).await {
|
||||
Ok(_) => visible.push(mapper::channel_model_to_channel_response(channel)),
|
||||
Err(HTTPError::Forbidden) => {},
|
||||
Err(error) => return Err(error),
|
||||
}
|
||||
}
|
||||
visible
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -80,7 +110,7 @@ pub async fn get_read_state(
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||
require_channel_member(&state, channel_id, user.id).await?;
|
||||
require_channel_permission(&state, &user, channel_id, ChannelPermission::READ_CHANNEL).await?;
|
||||
let read_state = state
|
||||
.repositories
|
||||
.read_state
|
||||
@@ -120,7 +150,7 @@ pub async fn set_read_state(
|
||||
Path(channel_id): Path<Uuid>,
|
||||
Json(payload): Json<SetReadStateRequest>,
|
||||
) -> Result<Json<ReadStateResponse>, HTTPError> {
|
||||
require_channel_member(&state, channel_id, user.id).await?;
|
||||
require_channel_permission(&state, &user, channel_id, ChannelPermission::READ_CHANNEL).await?;
|
||||
|
||||
if let Some(message_id) = payload.last_read_message_id {
|
||||
let message = state
|
||||
@@ -173,6 +203,7 @@ pub async fn set_read_state(
|
||||
tag = "Channels"
|
||||
)]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ChannelResponse>, HTTPError> {
|
||||
@@ -182,6 +213,7 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_channel_permission(&state, &user, id, ChannelPermission::READ_CHANNEL).await?;
|
||||
|
||||
Ok(Json(mapper::channel_model_to_channel_response(channel)))
|
||||
}
|
||||
@@ -195,9 +227,11 @@ pub async fn get_by_id(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn list_permissions(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(channel_id): Path<Uuid>,
|
||||
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -227,7 +261,7 @@ pub async fn list_permissions(
|
||||
)
|
||||
)]
|
||||
pub async fn create(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<CreateChannelRequest>,
|
||||
) -> Result<(StatusCode, Json<ChannelResponse>), HTTPError> {
|
||||
@@ -239,16 +273,20 @@ pub async fn create(
|
||||
.get_by_id(server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS).await?;
|
||||
} else {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
|
||||
// Vérifier que la catégorie existe si fournie
|
||||
if let Some(category_id) = payload.category_id {
|
||||
state
|
||||
let category = state
|
||||
.repositories
|
||||
.category
|
||||
.get_by_id(category_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?;
|
||||
if category.server_id != payload.server_id.ok_or(HTTPError::Forbidden)? { return Err(HTTPError::BadRequest("Category belongs to another server".to_string())); }
|
||||
}
|
||||
|
||||
let channel = state.services.channel.create_channel(payload).await?;
|
||||
@@ -278,18 +316,23 @@ pub async fn create(
|
||||
)
|
||||
)]
|
||||
pub async fn update(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateChannelRequest>,
|
||||
) -> Result<Json<ChannelResponse>, HTTPError> {
|
||||
// Vérifier l'existence
|
||||
state
|
||||
let original = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_channel_manager(&state, &user, id).await?;
|
||||
if payload.server_id != original.server_id {
|
||||
let server_id = payload.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS).await?;
|
||||
}
|
||||
|
||||
// Vérifier que le serveur existe si fourni
|
||||
if let Some(server_id) = payload.server_id {
|
||||
@@ -303,12 +346,13 @@ pub async fn update(
|
||||
|
||||
// Vérifier que la catégorie existe si fournie
|
||||
if let Some(category_id) = payload.category_id {
|
||||
state
|
||||
let category = state
|
||||
.repositories
|
||||
.category
|
||||
.get_by_id(category_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?;
|
||||
if Some(category.server_id) != payload.server_id { return Err(HTTPError::BadRequest("Category belongs to another server".to_string())); }
|
||||
}
|
||||
|
||||
let channel = state.services.channel.update_channel(id, payload).await?;
|
||||
@@ -334,10 +378,11 @@ pub async fn update(
|
||||
)
|
||||
)]
|
||||
pub async fn delete(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_channel_manager(&state, &user, id).await?;
|
||||
if state.services.channel.delete_channel(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
@@ -361,9 +406,11 @@ pub async fn delete(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn get_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -392,10 +439,12 @@ pub async fn get_user_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn set_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SetChannelPermissionRequest>,
|
||||
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
@@ -430,9 +479,11 @@ pub async fn set_user_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn remove_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
if state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -468,9 +519,11 @@ pub async fn remove_user_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn get_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.channel
|
||||
@@ -499,10 +552,12 @@ pub async fn get_role_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn set_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
Json(payload): Json<SetChannelPermissionRequest>,
|
||||
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
state
|
||||
.services
|
||||
.channel
|
||||
@@ -537,9 +592,11 @@ pub async fn set_role_permission(
|
||||
tag = "Channel Permissions"
|
||||
)]
|
||||
pub async fn remove_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_channel_manager(&state, &user, channel_id).await?;
|
||||
if state
|
||||
.repositories
|
||||
.channel
|
||||
|
||||
@@ -25,6 +25,7 @@ pub fn channel_model_to_channel_response_with_permission(
|
||||
updated_at: model.updated_at,
|
||||
unread_count: None,
|
||||
permission,
|
||||
voice_participants: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router};
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response {
|
||||
let mut req = request(method, uri, Body::from(body.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
router.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn channel_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let list = format!("/channels?server_id={server}");
|
||||
|
||||
// Un non-membre ne peut pas consulter les canaux du serveur.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let payload = json!({"server_id":server,"category_id":null,"channel_type":"text","name":"test-channel"});
|
||||
|
||||
// La création requiert MANAGE_CHANNELS, même pour un membre.
|
||||
assert_eq!(
|
||||
call(&router, Method::POST, "/channels", payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CHANNELS.bits()).await.unwrap();
|
||||
let created = call(&router, Method::POST, "/channels", payload.clone(), actor.clone()).await;
|
||||
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let data: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap();
|
||||
let uri = format!("/channels/{}", data["id"].as_str().unwrap());
|
||||
let permissions = format!("{uri}/permissions");
|
||||
|
||||
// Le droit de gestion permet de consulter et modifier les permissions du canal.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &permissions, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let direct = format!("{permissions}/users/{}", actor.id);
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &direct, json!({"permissions":1}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &direct, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
// Sans droit de gestion, les opérations sur le canal et ses permissions sont refusées.
|
||||
state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &permissions, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
for method in [Method::GET, Method::PUT, Method::DELETE] {
|
||||
assert_eq!(
|
||||
call(&router, method, &direct, json!({"permissions":1}), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_CHANNELS.bits()).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, payload, actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &direct, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
@@ -108,12 +108,9 @@ async fn create_channel(state: &AppState, ids: &[Uuid]) -> Result<channel::Model
|
||||
.services
|
||||
.realtime_registry
|
||||
.set_channel_users(channel.id, ids.iter().copied());
|
||||
state.event_bus.emit(
|
||||
"channel_created",
|
||||
ChannelCreatedEvent {
|
||||
channel: channel.clone(),
|
||||
},
|
||||
);
|
||||
state.event_bus.emit(ChannelCreatedEvent {
|
||||
channel: channel.clone(),
|
||||
});
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
|
||||
@@ -10,3 +10,41 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/conversations", get(handlers::list).post(handlers::create))
|
||||
.route("/conversations/{id}/fork", post(handlers::fork))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn conversation_routes_respect_authentication_and_membership() {
|
||||
let state = state().await;
|
||||
let owner = user(&state, false).await;
|
||||
let guest = user(&state, false).await;
|
||||
let outsider = user(&state, true).await;
|
||||
let routes = router().with_state(state);
|
||||
let list = routes.clone().oneshot(request(Method::GET, "/conversations", Body::empty(), Some(owner.clone()))).await.unwrap();
|
||||
assert_eq!(list.status(), StatusCode::OK);
|
||||
let create_body = format!(r#"{{"user_ids":["{}"]}}"#, guest.id);
|
||||
let mut create = request(Method::POST, "/conversations", Body::from(create_body.clone()), Some(owner.clone()));
|
||||
create.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
let created = routes.clone().oneshot(create).await.unwrap();
|
||||
assert_eq!(created.status(), StatusCode::OK);
|
||||
let bytes = to_bytes(created.into_body(), 1024 * 1024).await.unwrap();
|
||||
let id = serde_json::from_slice::<serde_json::Value>(&bytes).unwrap()["id"].as_str().unwrap().to_string();
|
||||
let fork_uri = format!("/conversations/{id}/fork");
|
||||
let fork_body = r#"{"user_ids":[]}"#;
|
||||
let mut forbidden = request(Method::POST, &fork_uri, Body::from(fork_body), Some(outsider));
|
||||
forbidden.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(routes.clone().oneshot(forbidden).await.unwrap().status(), StatusCode::FORBIDDEN);
|
||||
let mut allowed = request(Method::POST, &fork_uri, Body::from(fork_body), Some(guest));
|
||||
allowed.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(routes.clone().oneshot(allowed).await.unwrap().status(), StatusCode::OK);
|
||||
for (method, uri, body) in [(Method::GET, "/conversations", ""), (Method::POST, "/conversations", create_body.as_str()), (Method::POST, fork_uri.as_str(), fork_body)] {
|
||||
let result = routes.clone().oneshot(request(method, uri, Body::from(body.to_string()), None)).await.unwrap();
|
||||
assert_eq!(result.status(), StatusCode::UNAUTHORIZED, "{uri}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::domain::events::emoji::{EmojiCreatedEvent, EmojiDeletedEvent, EmojiUpdatedEvent};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::permissions::check_server_permission;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::services::media;
|
||||
use crate::{
|
||||
core::state::AppState,
|
||||
@@ -29,20 +32,38 @@ fn normalize_type(value: &str) -> Result<String, HTTPError> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn require_manage(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
server_id: Option<Uuid>,
|
||||
) -> Result<(), HTTPError> {
|
||||
let server_id = server_id.ok_or(HTTPError::Forbidden)?;
|
||||
if check_server_permission(state, user.id, server_id, ServerPermission::MANAGE_SERVER).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/emojis", params(EmojiQueryParams), responses((status = 200, body = [crate::domain::dto::emoji::EmojiResponse])), tag = "Emojis")]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(query): Query<EmojiQueryParams>,
|
||||
) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> {
|
||||
let mut result = Vec::new();
|
||||
for emoji in state.repositories.emoji.list(query.server_id).await? {
|
||||
result.push(mapper::response(emoji));
|
||||
if match emoji.server_id {
|
||||
Some(id) => check_server_permission(&state, user.id, id, ServerPermission::empty()).await?,
|
||||
None => true,
|
||||
} { result.push(mapper::response(emoji)); }
|
||||
}
|
||||
Ok(Json(result))
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<crate::domain::dto::emoji::EmojiResponse>, HTTPError> {
|
||||
@@ -52,11 +73,15 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if let Some(server_id) = emoji.server_id {
|
||||
if !check_server_permission(&state, user.id, server_id, ServerPermission::empty()).await? { return Err(HTTPError::Forbidden); }
|
||||
}
|
||||
Ok(Json(mapper::response(emoji)))
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
|
||||
pub async fn create(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
mut multipart: Multipart,
|
||||
) -> Result<(StatusCode, Json<crate::domain::dto::emoji::EmojiResponse>), HTTPError> {
|
||||
@@ -112,6 +137,7 @@ pub async fn create(
|
||||
"file is required for custom emojis".into(),
|
||||
));
|
||||
}
|
||||
require_manage(&state, &user, server_id).await?;
|
||||
if let Some(server_id) = server_id {
|
||||
state
|
||||
.repositories
|
||||
@@ -155,17 +181,15 @@ pub async fn create(
|
||||
..Default::default()
|
||||
};
|
||||
let created = state.services.emoji.create(model, name).await?;
|
||||
state.event_bus.emit(
|
||||
"emoji_created",
|
||||
EmojiCreatedEvent {
|
||||
emoji: created.clone(),
|
||||
},
|
||||
);
|
||||
state.event_bus.emit(EmojiCreatedEvent {
|
||||
emoji: created.clone(),
|
||||
});
|
||||
Ok((StatusCode::CREATED, Json(mapper::response(created))))
|
||||
}
|
||||
|
||||
#[utoipa::path(put, path = "/emojis/{id}", request_body = UpdateEmojiRequest, params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
|
||||
pub async fn update(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
Json(payload): Json<UpdateEmojiRequest>,
|
||||
@@ -176,6 +200,10 @@ pub async fn update(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_manage(&state, &user, existing.server_id).await?;
|
||||
if let Some(target) = payload.server_id {
|
||||
require_manage(&state, &user, Some(target)).await?;
|
||||
}
|
||||
let target_server_id = payload.server_id.or(existing.server_id);
|
||||
let target_name = payload.name.as_deref().unwrap_or(&existing.name);
|
||||
state
|
||||
@@ -201,13 +229,10 @@ pub async fn update(
|
||||
active.name = Set(EmojiService::normalize_name(&name)?);
|
||||
}
|
||||
let updated = state.repositories.emoji.update(active).await?;
|
||||
state.event_bus.emit(
|
||||
"emoji_updated",
|
||||
EmojiUpdatedEvent {
|
||||
previous,
|
||||
emoji: updated.clone(),
|
||||
},
|
||||
);
|
||||
state.event_bus.emit(EmojiUpdatedEvent {
|
||||
previous,
|
||||
emoji: updated.clone(),
|
||||
});
|
||||
Ok(Json(mapper::response(updated)))
|
||||
}
|
||||
|
||||
@@ -225,6 +250,7 @@ fn detect_mime(bytes: &[u8]) -> Option<String> {
|
||||
|
||||
#[utoipa::path(delete, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 204)), tag = "Emojis")]
|
||||
pub async fn delete(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
@@ -234,6 +260,7 @@ pub async fn delete(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_manage(&state, &user, model.server_id).await?;
|
||||
let deleted = state.repositories.emoji.delete(id).await?;
|
||||
if deleted {
|
||||
EmojiService::remove_asset(
|
||||
@@ -241,9 +268,7 @@ pub async fn delete(
|
||||
model.file_path.as_deref(),
|
||||
)
|
||||
.await;
|
||||
state
|
||||
.event_bus
|
||||
.emit("emoji_deleted", EmojiDeletedEvent { emoji: model });
|
||||
state.event_bus.emit(EmojiDeletedEvent { emoji: model });
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
Err(HTTPError::NotFound)
|
||||
@@ -251,6 +276,7 @@ pub async fn delete(
|
||||
}
|
||||
|
||||
pub async fn asset(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Response, HTTPError> {
|
||||
@@ -260,6 +286,9 @@ pub async fn asset(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if let Some(server_id) = model.server_id {
|
||||
if !check_server_permission(&state, user.id, server_id, ServerPermission::empty()).await? { return Err(HTTPError::Forbidden); }
|
||||
}
|
||||
let path = model.file_path.ok_or(HTTPError::NotFound)?;
|
||||
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path))
|
||||
.await
|
||||
|
||||
@@ -13,3 +13,164 @@ pub fn router() -> Router<AppState> {
|
||||
)
|
||||
.route("/emojis/{id}/asset", get(handlers::asset))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod permission_tests {
|
||||
use super::*;
|
||||
use crate::models::{emoji, server_user, server_user_permission};
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::message::routes::permission_tests::Fixture;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, StatusCode},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn emoji_create_requires_manage_server() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Un membre sans MANAGE_SERVER ne peut pas créer d'emoji.
|
||||
let boundary = "emoji-permission-test";
|
||||
let body = format!(
|
||||
"--{boundary}\r\nContent-Disposition: form-data; name=\"server_id\"\r\n\r\n{}\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"name\"\r\n\r\nwave\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"emoji_type\"\r\n\r\nunicode\r\n--{boundary}\r\nContent-Disposition: form-data; name=\"unicode_sequence\"\r\n\r\n👋\r\n--{boundary}--\r\n",
|
||||
fixture.state.default_server.id
|
||||
);
|
||||
let mime = format!("multipart/form-data; boundary={boundary}");
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/emojis",
|
||||
Body::from(body.clone()),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
server_user_permission::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(fixture.state.default_server.id),
|
||||
user_id: Set(fixture.user.id),
|
||||
permissions: Set(ServerPermission::MANAGE_SERVER.bits() as i64),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/emojis",
|
||||
Body::from(body),
|
||||
Some(&mime)
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emoji_read_requires_membership() {
|
||||
let fixture = Fixture::new().await;
|
||||
// L'emoji de serveur est lisible par un membre, mais pas par un utilisateur retiré.
|
||||
let item = emoji::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(fixture.state.default_server.id)),
|
||||
name: Set("wave".into()),
|
||||
emoji_type: Set("unicode".into()),
|
||||
unicode_sequence: Set(Some("👋".into())),
|
||||
supports_skin_tone: Set(false),
|
||||
file_path: Set(None),
|
||||
mime_type: Set(None),
|
||||
file_size: Set(None),
|
||||
is_animated: Set(false),
|
||||
sha256: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/emojis/{}", item.id);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
server_user::Entity::delete_many()
|
||||
.filter(server_user::Column::UserId.eq(fixture.user.id))
|
||||
.exec(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn emoji_update_and_delete_require_manage_server() {
|
||||
// Les deux opérations sont contrôlées séparément avec une fixture fraîche.
|
||||
for method in [Method::PUT, Method::DELETE] {
|
||||
let fixture = Fixture::new().await;
|
||||
let item = emoji::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(fixture.state.default_server.id)),
|
||||
name: Set("wave".into()),
|
||||
emoji_type: Set("unicode".into()),
|
||||
unicode_sequence: Set(Some("👋".into())),
|
||||
supports_skin_tone: Set(false),
|
||||
file_path: Set(None),
|
||||
mime_type: Set(None),
|
||||
file_size: Set(None),
|
||||
is_animated: Set(false),
|
||||
sha256: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/emojis/{}", item.id);
|
||||
let body = || Body::from(r#"{"name":"newwave"}"#);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
method.clone(),
|
||||
&uri,
|
||||
body(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
server_user_permission::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(fixture.state.default_server.id),
|
||||
user_id: Set(fixture.user.id),
|
||||
permissions: Set(ServerPermission::MANAGE_SERVER.bits() as i64),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let expected = if method == Method::PUT {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::NO_CONTENT
|
||||
};
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), method, &uri, body(), Some("application/json"))
|
||||
.await,
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+84
-77
@@ -1,4 +1,7 @@
|
||||
use crate::core::AppState;
|
||||
use crate::domain::events::category::{
|
||||
CategoryCreatedEvent, CategoryDeletedEvent, CategoryUpdatedEvent,
|
||||
};
|
||||
use crate::domain::events::channel::{
|
||||
ChannelCreatedEvent, ChannelDeletedEvent, ChannelUpdatedEvent,
|
||||
};
|
||||
@@ -7,8 +10,10 @@ use crate::domain::events::message::{
|
||||
MessageCreatedEvent, MessageDeletedEvent, MessageReactionAddedEvent,
|
||||
MessageReactionRemovedEvent, MessageUpdatedEvent,
|
||||
};
|
||||
use crate::domain::events::server::{ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent};
|
||||
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||
use crate::models::{category, server, server_user, user};
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use crate::models::{server_user, user};
|
||||
use crate::repositories::Repositories;
|
||||
use crate::routes::category::mapper::category_model_to_category_response;
|
||||
use crate::routes::channel::mapper::channel_model_to_channel_response;
|
||||
@@ -112,7 +117,16 @@ impl RealtimeRouter {
|
||||
|
||||
pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageCreatedEvent, _, _>("message_created", move |event| {
|
||||
event_bus.on_async::<VoicePresenceEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router.server_users(event.server_id).await;
|
||||
let action = if event.joined { "joined" } else { "left" };
|
||||
router.gateway.send_to_users(users, "VoicePresence", action, event);
|
||||
}
|
||||
});
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageCreatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let attachments = router
|
||||
@@ -142,7 +156,7 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageUpdatedEvent, _, _>("message_updated", move |event| {
|
||||
event_bus.on_async::<MessageUpdatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let reactions = router
|
||||
@@ -171,7 +185,7 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageDeletedEvent, _, _>("message_deleted", move |event| {
|
||||
event_bus.on_async::<MessageDeletedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router
|
||||
@@ -185,47 +199,41 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageReactionAddedEvent, _, _>(
|
||||
"message_reaction_added",
|
||||
move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router
|
||||
.services
|
||||
.realtime_registry
|
||||
.users_for_channel(event.channel_id);
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
"Reaction",
|
||||
"add",
|
||||
reaction_model_to_response(event.reaction),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
event_bus.on_async::<MessageReactionAddedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router
|
||||
.services
|
||||
.realtime_registry
|
||||
.users_for_channel(event.channel_id);
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
"Reaction",
|
||||
"add",
|
||||
reaction_model_to_response(event.reaction),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<MessageReactionRemovedEvent, _, _>(
|
||||
"message_reaction_removed",
|
||||
move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router
|
||||
.services
|
||||
.realtime_registry
|
||||
.users_for_channel(event.channel_id);
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
"Reaction",
|
||||
"remove",
|
||||
reaction_model_to_response(event.reaction),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
event_bus.on_async::<MessageReactionRemovedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router
|
||||
.services
|
||||
.realtime_registry
|
||||
.users_for_channel(event.channel_id);
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
"Reaction",
|
||||
"remove",
|
||||
reaction_model_to_response(event.reaction),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<ChannelCreatedEvent, _, _>("channel_created", move |event| {
|
||||
event_bus.on_async::<ChannelCreatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
if let Err(error) = router
|
||||
@@ -251,7 +259,7 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<ChannelUpdatedEvent, _, _>("channel_updated", move |event| {
|
||||
event_bus.on_async::<ChannelUpdatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let mut users = router
|
||||
@@ -274,7 +282,7 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<ChannelDeletedEvent, _, _>("channel_deleted", move |event| {
|
||||
event_bus.on_async::<ChannelDeletedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router
|
||||
@@ -292,9 +300,10 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<category::Model, _, _>("category_created", move |category| {
|
||||
event_bus.on_async::<CategoryCreatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let category = event.category;
|
||||
let users = router.server_users(category.server_id).await;
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
@@ -306,9 +315,10 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<category::Model, _, _>("category_updated", move |category| {
|
||||
event_bus.on_async::<CategoryUpdatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let category = event.category;
|
||||
let users = router.server_users(category.server_id).await;
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
@@ -320,9 +330,10 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<category::Model, _, _>("category_deleted", move |category| {
|
||||
event_bus.on_async::<CategoryDeletedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let category = event.category;
|
||||
let users = router.server_users(category.server_id).await;
|
||||
router
|
||||
.gateway
|
||||
@@ -331,9 +342,10 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<server::Model, _, _>("server_created", move |server| {
|
||||
event_bus.on_async::<ServerCreatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let server = event.server;
|
||||
let users = router.server_users(server.id).await;
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
@@ -345,9 +357,10 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<server::Model, _, _>("server_updated", move |server| {
|
||||
event_bus.on_async::<ServerUpdatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let server = event.server;
|
||||
let users = router.server_users(server.id).await;
|
||||
router.gateway.send_to_users(
|
||||
users,
|
||||
@@ -359,37 +372,31 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<(server::Model, Vec<Uuid>), _, _>(
|
||||
"server_deleted",
|
||||
move |(server, users)| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
router
|
||||
.gateway
|
||||
.send_to_users(users, "Server", "remove", server.id);
|
||||
}
|
||||
},
|
||||
);
|
||||
event_bus.on_async::<ServerDeletedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
router
|
||||
.gateway
|
||||
.send_to_users(event.audience, "Server", "remove", event.server.id);
|
||||
}
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<ServerTreeInvalidatedEvent, _, _>(
|
||||
"server_tree_invalidated",
|
||||
move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = match event.user_ids {
|
||||
Some(users) => users,
|
||||
None => router.server_users(event.server_id).await,
|
||||
};
|
||||
router
|
||||
.gateway
|
||||
.send_to_users(users, "ServerTree", "refresh", event.server_id);
|
||||
}
|
||||
},
|
||||
);
|
||||
event_bus.on_async::<ServerTreeInvalidatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = match event.user_ids {
|
||||
Some(users) => users,
|
||||
None => router.server_users(event.server_id).await,
|
||||
};
|
||||
router
|
||||
.gateway
|
||||
.send_to_users(users, "ServerTree", "refresh", event.server_id);
|
||||
}
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<EmojiCreatedEvent, _, _>("emoji_created", move |event| {
|
||||
event_bus.on_async::<EmojiCreatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router.emoji_users(event.emoji.server_id).await;
|
||||
@@ -403,7 +410,7 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<EmojiUpdatedEvent, _, _>("emoji_updated", move |event| {
|
||||
event_bus.on_async::<EmojiUpdatedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let mut users = router.emoji_users(event.emoji.server_id).await;
|
||||
@@ -418,7 +425,7 @@ impl RealtimeRouter {
|
||||
});
|
||||
|
||||
let router = Arc::clone(self);
|
||||
event_bus.on_async::<EmojiDeletedEvent, _, _>("emoji_deleted", move |event| {
|
||||
event_bus.on_async::<EmojiDeletedEvent>(move |event| {
|
||||
let router = Arc::clone(&router);
|
||||
async move {
|
||||
let users = router.emoji_users(event.emoji.server_id).await;
|
||||
|
||||
+112
-41
@@ -6,36 +6,59 @@ use crate::domain::dto::message::{
|
||||
use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::{channel, channel_user};
|
||||
use crate::http::permissions::check_channel_permission;
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::routes::message::mapper;
|
||||
use axum::{
|
||||
Json,
|
||||
extract::{Path, Query, State},
|
||||
http::StatusCode,
|
||||
};
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) async fn can_access(
|
||||
pub(crate) async fn require_channel_permission(
|
||||
state: &AppState,
|
||||
channel_id: Uuid,
|
||||
user_id: Uuid,
|
||||
) -> Result<bool, HTTPError> {
|
||||
let Some(channel) = channel::Entity::find_by_id(channel_id)
|
||||
.one(&state.db)
|
||||
.await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
if channel.channel_type != channel::ChannelType::DM {
|
||||
return Ok(true);
|
||||
is_superuser: bool,
|
||||
required: ChannelPermission,
|
||||
) -> Result<(), HTTPError> {
|
||||
let _ = is_superuser;
|
||||
if check_channel_permission(state, user_id, channel_id, required).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
fn allows_channel_permission(granted: ChannelPermission, required: ChannelPermission) -> bool {
|
||||
granted.contains(required)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn message_permissions_require_every_requested_bit() {
|
||||
let granted = ChannelPermission::READ_CHANNEL | ChannelPermission::SEND_MESSAGE;
|
||||
assert!(allows_channel_permission(
|
||||
granted,
|
||||
ChannelPermission::READ_CHANNEL
|
||||
));
|
||||
assert!(!allows_channel_permission(
|
||||
granted,
|
||||
ChannelPermission::SEND_MESSAGE | ChannelPermission::ATTACH_FILES
|
||||
));
|
||||
assert!(!allows_channel_permission(
|
||||
granted,
|
||||
ChannelPermission::EDIT_OTHERS_MESSAGES
|
||||
));
|
||||
assert!(!allows_channel_permission(
|
||||
ChannelPermission::empty(),
|
||||
ChannelPermission::READ_CHANNEL
|
||||
));
|
||||
}
|
||||
Ok(channel_user::Entity::find()
|
||||
.filter(channel_user::Column::ChannelId.eq(channel_id))
|
||||
.filter(channel_user::Column::UserId.eq(user_id))
|
||||
.one(&state.db)
|
||||
.await?
|
||||
.is_some())
|
||||
}
|
||||
|
||||
/// Liste une fenêtre paginée de messages
|
||||
@@ -64,11 +87,17 @@ pub async fn get_all(
|
||||
}
|
||||
|
||||
let params = mapper::query_params_to_message_filter(filters);
|
||||
if let Some(channel_id) = params.channel_id {
|
||||
if !can_access(&state, channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
}
|
||||
let channel_id = params
|
||||
.channel_id
|
||||
.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
|
||||
require_channel_permission(
|
||||
&state,
|
||||
channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
let page = state.repositories.message.filter(params).await?;
|
||||
let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect();
|
||||
let mut reactions = state
|
||||
@@ -126,9 +155,14 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if !can_access(&state, message.channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let reactions = state
|
||||
.services
|
||||
@@ -180,9 +214,11 @@ pub async fn create(
|
||||
.get_by_id(payload.channel_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Channel not found".to_string()))?;
|
||||
if !can_access(&state, channel.id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
let mut required = ChannelPermission::SEND_MESSAGE;
|
||||
if !payload.file_ids.is_empty() {
|
||||
required |= ChannelPermission::ATTACH_FILES;
|
||||
}
|
||||
require_channel_permission(&state, channel.id, user.id, user.is_superuser, required).await?;
|
||||
|
||||
if payload.content.trim().is_empty() && payload.file_ids.is_empty() {
|
||||
return Err(HTTPError::BadRequest(
|
||||
@@ -192,7 +228,7 @@ pub async fn create(
|
||||
|
||||
// Optionnel: vérifier reply_to_id
|
||||
if let Some(reply_id) = payload.reply_to_id {
|
||||
state
|
||||
let parent = state
|
||||
.repositories
|
||||
.message
|
||||
.get_by_id(reply_id)
|
||||
@@ -200,6 +236,11 @@ pub async fn create(
|
||||
.ok_or(HTTPError::BadRequest(
|
||||
"Parent message not found".to_string(),
|
||||
))?;
|
||||
if parent.channel_id != channel.id {
|
||||
return Err(HTTPError::BadRequest(
|
||||
"Parent message belongs to another channel".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
let message = state
|
||||
@@ -274,9 +315,19 @@ pub async fn update(
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
// Vérifier que l'utilisateur est l'auteur
|
||||
if message.user_id != user.id && !user.is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
let required = if message.user_id == user.id {
|
||||
ChannelPermission::EDIT_OWN_MESSAGE
|
||||
} else {
|
||||
ChannelPermission::EDIT_OTHERS_MESSAGES
|
||||
};
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
required,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let message = state
|
||||
.services
|
||||
@@ -332,9 +383,14 @@ pub async fn add_reaction(
|
||||
.get_by_id(message_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if !can_access(&state, message.channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS,
|
||||
)
|
||||
.await?;
|
||||
let (reaction, created) = state
|
||||
.services
|
||||
.message_reaction
|
||||
@@ -378,9 +434,14 @@ pub async fn remove_reaction(
|
||||
.get_by_id(message_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
if !can_access(&state, message.channel_id, user.id).await? {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
ChannelPermission::READ_CHANNEL,
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.services
|
||||
.message_reaction
|
||||
@@ -420,9 +481,19 @@ pub async fn delete(
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
if message.user_id != user.id && !user.is_superuser {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
let required = if message.user_id == user.id {
|
||||
ChannelPermission::DELETE_OWN_MESSAGE
|
||||
} else {
|
||||
ChannelPermission::DELETE_OTHERS_MESSAGES
|
||||
};
|
||||
require_channel_permission(
|
||||
&state,
|
||||
message.channel_id,
|
||||
user.id,
|
||||
user.is_superuser,
|
||||
required,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let attachment_ids: Vec<_> = state
|
||||
.repositories
|
||||
|
||||
@@ -20,3 +20,327 @@ pub fn router() -> Router<AppState> {
|
||||
axum::routing::delete(handlers::remove_reaction),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod permission_tests {
|
||||
use super::*;
|
||||
use crate::config::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use crate::core::App;
|
||||
use crate::http::context::{CurrentUser, RequestContext};
|
||||
use crate::models::{channel, channel_user_permission, emoji, server_user, user};
|
||||
use crate::permissions::ChannelPermission;
|
||||
use axum::{
|
||||
body::Body,
|
||||
http::{Method, Request, StatusCode},
|
||||
};
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use std::time::Instant;
|
||||
use tower::ServiceExt;
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) struct Fixture {
|
||||
pub state: AppState,
|
||||
pub user: user::Model,
|
||||
pub channel_id: Uuid,
|
||||
}
|
||||
|
||||
impl Fixture {
|
||||
pub async fn new() -> Self {
|
||||
let mut config: AppConfig = toml::from_str(DEFAULT_CONFIG_TOML).unwrap();
|
||||
config.database.url = "sqlite::memory:".into();
|
||||
config.network.stun_servers.clear();
|
||||
config.media.root =
|
||||
format!("src/routes/attachment/.permission-test-{}", Uuid::new_v4());
|
||||
let state = App::build(config).await.unwrap().state;
|
||||
let user = user::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
username: Set(format!("test-{}", Uuid::new_v4())),
|
||||
password: Set(String::new()),
|
||||
pub_key: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
is_superuser: Set(false),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
server_user::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(state.default_server.id),
|
||||
user_id: Set(user.id),
|
||||
username: Set(None),
|
||||
joined_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let channel = channel::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(state.default_server.id)),
|
||||
category_id: Set(None),
|
||||
channel_type: Set(channel::ChannelType::Text),
|
||||
name: Set(Some("test".into())),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
Self {
|
||||
state,
|
||||
user,
|
||||
channel_id: channel.id,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn grant(&self, permissions: ChannelPermission) {
|
||||
channel_user_permission::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
channel_id: Set(self.channel_id),
|
||||
user_id: Set(self.user.id),
|
||||
permissions: Set(permissions.bits() as i64),
|
||||
}
|
||||
.insert(&self.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
pub async fn request(
|
||||
&self,
|
||||
router: Router<AppState>,
|
||||
method: Method,
|
||||
uri: &str,
|
||||
body: Body,
|
||||
content_type: Option<&str>,
|
||||
) -> StatusCode {
|
||||
let mut builder = Request::builder().method(method.clone()).uri(uri);
|
||||
if let Some(content_type) = content_type {
|
||||
builder = builder.header("content-type", content_type);
|
||||
}
|
||||
let mut request = builder.body(body).unwrap();
|
||||
request.extensions_mut().insert(RequestContext {
|
||||
request_id: Uuid::new_v4(),
|
||||
started_at: Instant::now(),
|
||||
method,
|
||||
uri: uri.parse().unwrap(),
|
||||
user: Some(CurrentUser(self.user.clone())),
|
||||
});
|
||||
router
|
||||
.with_state(self.state.clone())
|
||||
.oneshot(request)
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_list_requires_read_channel() {
|
||||
let fixture = Fixture::new().await;
|
||||
let uri = format!("/messages?channel_id={}", fixture.channel_id);
|
||||
// L'accès aux messages du canal demande READ_CHANNEL.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::READ_CHANNEL).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::GET, &uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_create_requires_send_message() {
|
||||
let fixture = Fixture::new().await;
|
||||
let payload = format!(
|
||||
r#"{{"channel_id":"{}","content":"hello","file_ids":[]}}"#,
|
||||
fixture.channel_id
|
||||
);
|
||||
let send = || Body::from(payload.clone());
|
||||
// SEND_MESSAGE est distinct du droit de lire le canal.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/messages",
|
||||
send(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::SEND_MESSAGE).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
"/messages",
|
||||
send(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn message_edit_and_delete_require_own_permissions() {
|
||||
// L'édition et la suppression d'un message propre ont des permissions distinctes.
|
||||
for (method, permission, success) in [
|
||||
(
|
||||
Method::PUT,
|
||||
ChannelPermission::EDIT_OWN_MESSAGE,
|
||||
StatusCode::OK,
|
||||
),
|
||||
(
|
||||
Method::DELETE,
|
||||
ChannelPermission::DELETE_OWN_MESSAGE,
|
||||
StatusCode::NO_CONTENT,
|
||||
),
|
||||
] {
|
||||
let fixture = Fixture::new().await;
|
||||
let message = fixture
|
||||
.state
|
||||
.services
|
||||
.message
|
||||
.create_message_with_attachments(
|
||||
fixture.channel_id,
|
||||
fixture.user.id,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let uri = format!("/messages/{}", message.id);
|
||||
let body = || Body::from(r#"{"content":"edited"}"#);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
method.clone(),
|
||||
&uri,
|
||||
body(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(permission).await;
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), method, &uri, body(), Some("application/json"))
|
||||
.await,
|
||||
success
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reactions_require_read_and_add_permissions() {
|
||||
let fixture = Fixture::new().await;
|
||||
// Prépare un message et un emoji afin de tester les deux opérations de réaction.
|
||||
let message = fixture
|
||||
.state
|
||||
.services
|
||||
.message
|
||||
.create_message_with_attachments(
|
||||
fixture.channel_id,
|
||||
fixture.user.id,
|
||||
"hello".into(),
|
||||
Vec::new(),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let emoji = emoji::ActiveModel {
|
||||
id: Set(Uuid::new_v4()),
|
||||
server_id: Set(Some(fixture.state.default_server.id)),
|
||||
name: Set("wave".into()),
|
||||
emoji_type: Set("unicode".into()),
|
||||
unicode_sequence: Set(Some("👋".into())),
|
||||
supports_skin_tone: Set(false),
|
||||
file_path: Set(None),
|
||||
mime_type: Set(None),
|
||||
file_size: Set(None),
|
||||
is_animated: Set(false),
|
||||
sha256: Set(None),
|
||||
created_at: Set(chrono::Utc::now()),
|
||||
updated_at: Set(chrono::Utc::now()),
|
||||
}
|
||||
.insert(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let add_uri = format!("/messages/{}/reactions", message.id);
|
||||
let remove_uri = format!("{add_uri}/{}", emoji.id);
|
||||
let payload = || Body::from(format!(r#"{{"emoji_id":"{}"}}"#, emoji.id));
|
||||
// Sans permission, l'ajout et le retrait sont refusés.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
&add_uri,
|
||||
payload(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::DELETE, &remove_uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
fixture.grant(ChannelPermission::READ_CHANNEL).await;
|
||||
// Lire le canal seul ne suffit pas pour ajouter une réaction.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
&add_uri,
|
||||
payload(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
channel_user_permission::Entity::delete_many()
|
||||
.filter(channel_user_permission::Column::UserId.eq(fixture.user.id))
|
||||
.exec(&fixture.state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
fixture
|
||||
.grant(ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS)
|
||||
.await;
|
||||
// L'ajout requiert les deux droits; le retrait est ensuite autorisé aussi.
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(
|
||||
router(),
|
||||
Method::POST,
|
||||
&add_uri,
|
||||
payload(),
|
||||
Some("application/json")
|
||||
)
|
||||
.await,
|
||||
StatusCode::CREATED
|
||||
);
|
||||
assert_eq!(
|
||||
fixture
|
||||
.request(router(), Method::DELETE, &remove_uri, Body::empty(), None)
|
||||
.await,
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-30
@@ -6,6 +6,7 @@ use crate::domain::dto::user::UserResponse;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use crate::routes::role::mapper;
|
||||
use crate::routes::user::mapper as user_mapper;
|
||||
use axum::{
|
||||
@@ -15,36 +16,15 @@ use axum::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn require_permission(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
server_id: Uuid,
|
||||
permission: ServerPermission,
|
||||
) -> Result<(), HTTPError> {
|
||||
if user.is_superuser {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let granted = state
|
||||
.repositories
|
||||
.server
|
||||
.get_user_permission(server_id, user.id)
|
||||
.await?
|
||||
.map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
|
||||
.unwrap_or_default();
|
||||
|
||||
if granted.contains(permission) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
#[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")]
|
||||
pub async fn get_all(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<RoleQueryParams>,
|
||||
) -> Result<Json<Vec<RoleResponse>>, HTTPError> {
|
||||
let server_id = filters.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
state.repositories.server.get_user(server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
let roles = match filters.server_id {
|
||||
Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?,
|
||||
None => state.repositories.role.get_all().await?,
|
||||
@@ -60,6 +40,7 @@ pub async fn get_all(
|
||||
|
||||
#[utoipa::path(get, path = "/roles/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = RoleResponse), (status = 404)), tag = "Roles")]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<RoleResponse>, HTTPError> {
|
||||
@@ -69,6 +50,7 @@ pub async fn get_by_id(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
state.repositories.server.get_user(role.server_id, user.id).await?.ok_or(HTTPError::Forbidden)?;
|
||||
Ok(Json(mapper::role_model_to_role_response(role)))
|
||||
}
|
||||
|
||||
@@ -84,7 +66,7 @@ pub async fn create(
|
||||
.get_by_id(payload.server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
payload.server_id,
|
||||
@@ -116,7 +98,7 @@ pub async fn update(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -148,7 +130,7 @@ pub async fn delete(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -174,7 +156,7 @@ pub async fn get_members(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -201,7 +183,7 @@ pub async fn add_member(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
@@ -235,7 +217,7 @@ pub async fn remove_member(
|
||||
.get_by_id(id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_permission(
|
||||
require_server_permission(
|
||||
&state,
|
||||
&user,
|
||||
role.server_id,
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::{to_bytes, Body}, http::{Method, StatusCode}, Router};
|
||||
use serde_json::{json, Value};
|
||||
use tower::ServiceExt;
|
||||
|
||||
async fn call(router: &Router, method: Method, uri: &str, body: Value, actor: crate::models::user::Model) -> axum::response::Response {
|
||||
let mut req = request(method, uri, Body::from(body.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
router.clone().oneshot(req).await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn role_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let list = format!("/roles?server_id={server}");
|
||||
|
||||
// La liste est inaccessible avant l'adhésion au serveur.
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &list, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
let payload = json!({"server_id":server,"name":"test-role"});
|
||||
|
||||
// Un membre doit aussi disposer de MANAGE_ROLES pour créer un rôle.
|
||||
assert_eq!(
|
||||
call(&router, Method::POST, "/roles", payload.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_ROLES.bits()).await.unwrap();
|
||||
let created = call(&router, Method::POST, "/roles", payload, actor.clone()).await;
|
||||
|
||||
assert_eq!(created.status(), StatusCode::CREATED);
|
||||
let id: Value = serde_json::from_slice(&to_bytes(created.into_body(), 1024 * 1024).await.unwrap()).unwrap();
|
||||
let uri = format!("/roles/{}", id["id"].as_str().unwrap());
|
||||
assert_eq!(
|
||||
call(&router, Method::GET, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
|
||||
// Sans MANAGE_ROLES, ni la modification ni la suppression ne sont permises.
|
||||
state.repositories.server.set_user_permission(server, actor.id, 0).await.unwrap();
|
||||
let update = json!({"name":"renamed","is_default":false});
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, update.clone(), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, ServerPermission::MANAGE_ROLES.bits()).await.unwrap();
|
||||
assert_eq!(
|
||||
call(&router, Method::PUT, &uri, update, actor.clone())
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
assert_eq!(
|
||||
call(&router, Method::DELETE, &uri, json!(null), actor)
|
||||
.await
|
||||
.status(),
|
||||
StatusCode::NO_CONTENT
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ use crate::core::AppState;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::rtc::ws_entrypoint::ws_entrypoint_handler;
|
||||
use crate::models::channel::{self, ChannelType};
|
||||
use axum::extract::{Path, State, WebSocketUpgrade};
|
||||
use axum::response::IntoResponse;
|
||||
use uuid::Uuid;
|
||||
@@ -12,13 +13,22 @@ pub async fn ws_handler(
|
||||
State(state): State<AppState>,
|
||||
CurrentUser(user): CurrentUser,
|
||||
) -> Result<impl IntoResponse, HTTPError> {
|
||||
let channel = checked_channel(&state, channel_id, user.id).await?;
|
||||
Ok(ws.on_upgrade(move |socket| ws_entrypoint_handler(socket, state, channel, user)))
|
||||
}
|
||||
|
||||
|
||||
async fn checked_channel(state: &AppState, channel_id: Uuid, user_id: Uuid) -> Result<channel::Model, HTTPError> {
|
||||
let channel = state
|
||||
.repositories
|
||||
.channel
|
||||
.get_by_id(channel_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
// todo : check des permissions
|
||||
// aiinsi que vérifier qu'on est sur un chan vocal OU un chan de message privé
|
||||
Ok(ws.on_upgrade(move |socket| ws_entrypoint_handler(socket, state, channel, user)))
|
||||
if channel.channel_type != ChannelType::Voice { return Err(HTTPError::NotFound); }
|
||||
let server_id = channel.server_id.ok_or(HTTPError::Forbidden)?;
|
||||
if state.repositories.server.get_user(server_id, user_id).await?.is_none() {
|
||||
return Err(HTTPError::Forbidden);
|
||||
}
|
||||
Ok(channel)
|
||||
}
|
||||
|
||||
@@ -6,3 +6,4 @@ use axum::routing::get;
|
||||
pub fn router() -> Router<AppState> {
|
||||
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
use crate::core::state::AppState;
|
||||
use crate::domain::dto::server::{
|
||||
CreateServerRequest, JoinServerRequest, ServerResponse, ServerRolePermissionResponse,
|
||||
ServerTreeResponse,
|
||||
ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest,
|
||||
ServerTreeResponse, ServerUserPermissionResponse, SetServerPermissionRequest,
|
||||
UpdateServerRequest,
|
||||
};
|
||||
use crate::http::context::{CurrentUser, Superuser};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::http::permissions::check_server_permission;
|
||||
use crate::routes::server::mapper;
|
||||
use axum::{
|
||||
Json,
|
||||
@@ -15,25 +16,13 @@ use axum::{
|
||||
};
|
||||
use uuid::Uuid;
|
||||
|
||||
async fn require_server_permission(
|
||||
pub(crate) async fn require_server_permission(
|
||||
state: &AppState,
|
||||
user: &CurrentUser,
|
||||
server_id: Uuid,
|
||||
permission: ServerPermission,
|
||||
) -> Result<(), HTTPError> {
|
||||
if user.is_superuser {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let granted = state
|
||||
.repositories
|
||||
.server
|
||||
.get_user_permission(server_id, user.id)
|
||||
.await?
|
||||
.map(|value| ServerPermission::from_bits_truncate(value.permissions as u64))
|
||||
.unwrap_or_default();
|
||||
|
||||
if granted.contains(permission) {
|
||||
if check_server_permission(state, user.id, server_id, permission).await? {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(HTTPError::Forbidden)
|
||||
@@ -90,9 +79,11 @@ pub async fn get_all(
|
||||
tag = "Servers"
|
||||
)]
|
||||
pub async fn get_by_id(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<Json<ServerResponse>, HTTPError> {
|
||||
state.repositories.server.get_user(id, user.id).await?.ok_or(HTTPError::NotFound)?;
|
||||
let server = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -229,10 +220,11 @@ pub async fn update(
|
||||
)
|
||||
)]
|
||||
pub async fn delete(
|
||||
_admin: Superuser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(id): Path<Uuid>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?;
|
||||
if state.services.server.delete_server(id).await? {
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
} else {
|
||||
@@ -256,9 +248,11 @@ pub async fn delete(
|
||||
tag = "Server Permissions"
|
||||
)]
|
||||
pub async fn get_user_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((server_id, user_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ServerUserPermissionResponse>, HTTPError> {
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -278,9 +272,11 @@ pub async fn get_user_permission(
|
||||
tag = "Server Permissions"
|
||||
)]
|
||||
pub async fn list_user_permissions(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path(server_id): Path<Uuid>,
|
||||
) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> {
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
|
||||
state
|
||||
.repositories
|
||||
.server
|
||||
@@ -336,10 +332,6 @@ pub async fn set_user_permission(
|
||||
.server
|
||||
.set_user_permission(server_id, user_id, payload.permissions)
|
||||
.await?;
|
||||
state
|
||||
.event_bus
|
||||
.emit("server_user_permission_updated", (server_id, user_id));
|
||||
|
||||
let permission = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -347,6 +339,13 @@ pub async fn set_user_permission(
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
state.event_bus.emit(
|
||||
crate::domain::events::server_permission::ServerUserPermissionUpdatedEvent {
|
||||
server_id,
|
||||
user_id,
|
||||
permission: Some(permission.clone()),
|
||||
},
|
||||
);
|
||||
Ok(Json(mapper::server_user_permission_to_response(permission)))
|
||||
}
|
||||
|
||||
@@ -386,9 +385,13 @@ pub async fn remove_user_permission(
|
||||
.server
|
||||
.remove_user_permission(server_id, user_id)
|
||||
.await?;
|
||||
state
|
||||
.event_bus
|
||||
.emit("server_user_permission_updated", (server_id, user_id));
|
||||
state.event_bus.emit(
|
||||
crate::domain::events::server_permission::ServerUserPermissionUpdatedEvent {
|
||||
server_id,
|
||||
user_id,
|
||||
permission: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -409,9 +412,11 @@ pub async fn remove_user_permission(
|
||||
tag = "Server Permissions"
|
||||
)]
|
||||
pub async fn get_role_permission(
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Path((server_id, role_id)): Path<(Uuid, Uuid)>,
|
||||
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
|
||||
let permission = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -449,10 +454,6 @@ pub async fn set_role_permission(
|
||||
.server
|
||||
.set_role_permission(server_id, role_id, payload.permissions)
|
||||
.await?;
|
||||
state
|
||||
.event_bus
|
||||
.emit("server_role_permission_updated", (role_id, server_id));
|
||||
|
||||
let permission = state
|
||||
.repositories
|
||||
.server
|
||||
@@ -460,6 +461,13 @@ pub async fn set_role_permission(
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
state.event_bus.emit(
|
||||
crate::domain::events::server_permission::ServerRolePermissionUpdatedEvent {
|
||||
server_id,
|
||||
role_id,
|
||||
permission: Some(permission.clone()),
|
||||
},
|
||||
);
|
||||
Ok(Json(mapper::server_role_permission_to_response(permission)))
|
||||
}
|
||||
|
||||
@@ -499,9 +507,13 @@ pub async fn remove_role_permission(
|
||||
.server
|
||||
.remove_role_permission(server_id, role_id)
|
||||
.await?;
|
||||
state
|
||||
.event_bus
|
||||
.emit("server_role_permission_updated", (role_id, server_id));
|
||||
state.event_bus.emit(
|
||||
crate::domain::events::server_permission::ServerRolePermissionUpdatedEvent {
|
||||
server_id,
|
||||
role_id,
|
||||
permission: None,
|
||||
},
|
||||
);
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -543,5 +555,6 @@ pub async fn get_tree(
|
||||
tree.channels,
|
||||
tree.categories,
|
||||
tree.unread_counts,
|
||||
&state.rtc.rooms,
|
||||
)))
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ use crate::domain::dto::server::{
|
||||
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
|
||||
ServerTreeResponse, ServerUserPermissionResponse, UpdateServerRequest,
|
||||
};
|
||||
use crate::models::{server, server_item_order, server_role_permission, server_user_permission};
|
||||
use crate::models::{channel::ChannelType, server, server_item_order, server_role_permission, server_user_permission};
|
||||
use crate::repositories::types::{CategoryWithPermissions, ChannelWithPermissions};
|
||||
use crate::rtc::VoiceRoomManager;
|
||||
use crate::routes::category::mapper::category_model_to_category_response_with_permission;
|
||||
use crate::routes::channel::mapper::channel_model_to_channel_response_with_permission;
|
||||
use sea_orm::Set;
|
||||
@@ -77,6 +78,7 @@ pub fn build_server_tree(
|
||||
channels: Vec<ChannelWithPermissions>,
|
||||
categories: Vec<CategoryWithPermissions>,
|
||||
unread_counts: HashMap<Uuid, u64>,
|
||||
rooms: &VoiceRoomManager,
|
||||
) -> ServerTreeResponse {
|
||||
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
|
||||
.into_iter()
|
||||
@@ -135,6 +137,7 @@ pub fn build_server_tree(
|
||||
Some(chan_perm_bits),
|
||||
);
|
||||
response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0));
|
||||
if response.channel_type == ChannelType::Voice { response.voice_participants = Some(rooms.participants(response.id)); }
|
||||
response
|
||||
})
|
||||
.collect();
|
||||
@@ -156,6 +159,7 @@ pub fn build_server_tree(
|
||||
Some(chan_perm_bits),
|
||||
);
|
||||
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0));
|
||||
if chan_response.channel_type == ChannelType::Voice { chan_response.voice_participants = Some(rooms.participants(chan_response.id)); }
|
||||
root_items.push((
|
||||
ServerExplorerItemResponse::Channel(chan_response),
|
||||
order_key,
|
||||
@@ -250,6 +254,7 @@ mod tests {
|
||||
],
|
||||
vec![category],
|
||||
HashMap::new(),
|
||||
&VoiceRoomManager::new(),
|
||||
);
|
||||
|
||||
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else {
|
||||
@@ -258,4 +263,23 @@ mod tests {
|
||||
assert_eq!(channels[0].name.as_deref(), Some("first"));
|
||||
assert_eq!(channels[1].name.as_deref(), Some("second"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_includes_voice_participants_only_for_voice_channels() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let server = Uuid::new_v4();
|
||||
let voice_id = Uuid::new_v4();
|
||||
let text_id = Uuid::new_v4();
|
||||
let user = crate::rtc::VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
rooms.join_user(server, voice_id, Uuid::new_v4(), user.clone());
|
||||
let mut voice = channel(voice_id, None, "voice");
|
||||
voice.channel.channel_type = channel::ChannelType::Voice;
|
||||
let tree = build_server_tree(vec![], vec![voice, channel(text_id, None, "text")], vec![], HashMap::new(), &rooms);
|
||||
let json = serde_json::to_value(tree).unwrap();
|
||||
let items = json["items"].as_array().unwrap();
|
||||
let voice = items.iter().find(|item| item["Channel"]["id"] == voice_id.to_string()).unwrap();
|
||||
let text = items.iter().find(|item| item["Channel"]["id"] == text_id.to_string()).unwrap();
|
||||
assert_eq!(voice["Channel"]["voice_participants"], serde_json::json!([user]));
|
||||
assert!(text["Channel"].get("voice_participants").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,3 +3,6 @@ pub mod handlers;
|
||||
pub mod mapper;
|
||||
pub mod routes;
|
||||
pub mod service;
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) mod tests;
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
use crate::config::AppConfig;
|
||||
use crate::core::{App, AppState};
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::user;
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::extract::{Path, State};
|
||||
use sea_orm::{ActiveModelTrait, Set};
|
||||
use uuid::Uuid;
|
||||
|
||||
pub(crate) async fn fixture() -> (AppState, CurrentUser, Uuid) {
|
||||
let mut config = AppConfig::load().unwrap();
|
||||
config.database.url = "sqlite::memory:".to_string();
|
||||
let state = App::build(config).await.unwrap().state;
|
||||
let account = user::ActiveModel {
|
||||
username: Set(format!("test-{}", Uuid::new_v4())),
|
||||
password: Set("unused".to_string()),
|
||||
is_superuser: Set(false),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&state.db)
|
||||
.await
|
||||
.unwrap();
|
||||
let server_id = state.default_server.id;
|
||||
(state, CurrentUser(account), server_id)
|
||||
}
|
||||
|
||||
pub(crate) async fn grant(state: &AppState, user: &CurrentUser, server_id: Uuid, permission: ServerPermission) {
|
||||
state.repositories.server.add_user(server_id, user.id).await.unwrap();
|
||||
state.repositories.server.set_user_permission(server_id, user.id, permission.bits()).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_read_requires_membership_and_member_can_read() {
|
||||
let (state, user, server_id) = fixture().await;
|
||||
|
||||
// Un utilisateur extérieur ne voit pas le serveur; un membre peut le consulter.
|
||||
assert!(matches!(super::handlers::get_by_id(user.clone(), State(state.clone()), Path(server_id)).await, Err(HTTPError::NotFound)));
|
||||
|
||||
grant(&state, &user, server_id, ServerPermission::empty()).await;
|
||||
assert!(super::handlers::get_by_id(user, State(state), Path(server_id)).await.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn server_permission_list_requires_manage_members() {
|
||||
let (state, user, server_id) = fixture().await;
|
||||
grant(&state, &user, server_id, ServerPermission::empty()).await;
|
||||
|
||||
// L'appartenance seule ne permet pas de gérer les permissions des membres.
|
||||
assert!(matches!(super::handlers::list_user_permissions(user.clone(), State(state.clone()), Path(server_id)).await, Err(HTTPError::Forbidden)));
|
||||
|
||||
state.repositories.server.set_user_permission(server_id, user.id, ServerPermission::MANAGE_MEMBERS.bits()).await.unwrap();
|
||||
assert!(super::handlers::list_user_permissions(user, State(state), Path(server_id)).await.is_ok());
|
||||
}
|
||||
@@ -3,6 +3,8 @@ use crate::domain::dto::server_item_order::ReorderServerItemRequest;
|
||||
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||
use crate::http::context::CurrentUser;
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::permissions::ServerPermission;
|
||||
use crate::routes::server::handlers::require_server_permission;
|
||||
use axum::http::StatusCode;
|
||||
use axum::{Json, extract::State};
|
||||
|
||||
@@ -15,7 +17,7 @@ use axum::{Json, extract::State};
|
||||
security(("bearerAuth" = []))
|
||||
)]
|
||||
pub async fn reorder(
|
||||
_user: CurrentUser,
|
||||
user: CurrentUser,
|
||||
State(state): State<AppState>,
|
||||
Json(payload): Json<ReorderServerItemRequest>,
|
||||
) -> Result<StatusCode, HTTPError> {
|
||||
@@ -26,15 +28,13 @@ pub async fn reorder(
|
||||
.get_by_id(server_id)
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_CHANNELS | ServerPermission::MANAGE_CATEGORIES).await?;
|
||||
|
||||
state.services.server_order.reorder(payload).await?;
|
||||
state.event_bus.emit(
|
||||
"server_tree_invalidated",
|
||||
ServerTreeInvalidatedEvent {
|
||||
server_id,
|
||||
user_ids: None,
|
||||
},
|
||||
);
|
||||
state.event_bus.emit(ServerTreeInvalidatedEvent {
|
||||
server_id,
|
||||
user_ids: None,
|
||||
});
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
pub mod handlers;
|
||||
pub mod routes;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use crate::permissions::ServerPermission;
|
||||
use axum::{body::Body, http::{Method, StatusCode}};
|
||||
use serde_json::json;
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn reorder_requires_both_management_permissions_over_http() {
|
||||
let state = state().await;
|
||||
let server = state.default_server.id;
|
||||
let actor = user(&state, false).await;
|
||||
state.repositories.server.add_user(server, actor.id).await.unwrap();
|
||||
let router = super::routes::router().with_state(state.clone());
|
||||
let category = state.services.category.create_category(server, "reorder-test".into()).await.unwrap();
|
||||
let payload = json!({"server_id":server,"resource_id":category.id,"resource_type":"category","parent_category_id":null,"reference":null,"position":"after"});
|
||||
|
||||
// Le réordonnancement exige les droits adaptés à la catégorie et aux canaux.
|
||||
for permissions in [
|
||||
ServerPermission::empty(),
|
||||
ServerPermission::MANAGE_CHANNELS,
|
||||
ServerPermission::MANAGE_CATEGORIES,
|
||||
] {
|
||||
state.repositories.server.set_user_permission(server, actor.id, permissions.bits()).await.unwrap();
|
||||
|
||||
let mut req = request(Method::PUT, "/server-item-orders/reorder", Body::from(payload.to_string()), Some(actor.clone()));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(router.clone().oneshot(req).await.unwrap().status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
|
||||
state.repositories.server.set_user_permission(server, actor.id, (ServerPermission::MANAGE_CHANNELS | ServerPermission::MANAGE_CATEGORIES).bits()).await.unwrap();
|
||||
|
||||
let mut req = request(Method::PUT, "/server-item-orders/reorder", Body::from(payload.to_string()), Some(actor));
|
||||
req.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::NO_CONTENT);
|
||||
}
|
||||
@@ -28,6 +28,7 @@ use uuid::Uuid;
|
||||
)
|
||||
)]
|
||||
pub async fn get_all(
|
||||
_admin: Superuser,
|
||||
State(state): State<AppState>,
|
||||
Query(filters): Query<UserQueryParams>,
|
||||
) -> Result<Json<Vec<UserResponse>>, HTTPError> {
|
||||
|
||||
@@ -13,3 +13,38 @@ pub fn router() -> Router<AppState> {
|
||||
.delete(handlers::delete),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::http::test_support::{request, state, user};
|
||||
use axum::{body::Body, http::{Method, StatusCode}};
|
||||
use tower::ServiceExt;
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_routes_require_superuser() {
|
||||
let state = state().await;
|
||||
let admin = user(&state, true).await;
|
||||
let member = user(&state, false).await;
|
||||
let target = user(&state, false).await;
|
||||
let routes = router().with_state(state);
|
||||
let cases = [
|
||||
(Method::GET, "/users".to_string(), ""),
|
||||
(Method::GET, format!("/users/{}", target.id), ""),
|
||||
(Method::POST, "/users".to_string(), r#"{"username":"new-user","password":"password123","pub_key":null,"is_superuser":false}"#),
|
||||
(Method::PUT, format!("/users/{}", target.id), r#"{"username":"renamed-user","pub_key":null,"is_superuser":false}"#),
|
||||
(Method::DELETE, format!("/users/{}", target.id), ""),
|
||||
];
|
||||
for (method, uri, body) in cases {
|
||||
let denied = routes.clone().oneshot(request(method.clone(), &uri, Body::from(body.to_string()), Some(member.clone()))).await.unwrap();
|
||||
assert_eq!(denied.status(), StatusCode::FORBIDDEN, "{method} {uri}");
|
||||
let missing = routes.clone().oneshot(request(method.clone(), &uri, Body::from(body.to_string()), None)).await.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::UNAUTHORIZED, "{method} {uri}");
|
||||
let mut allowed = request(method.clone(), &uri, Body::from(body.to_string()), Some(admin.clone()));
|
||||
allowed.headers_mut().insert("content-type", "application/json".parse().unwrap());
|
||||
let result = routes.clone().oneshot(allowed).await.unwrap();
|
||||
let expected = if method == Method::POST { StatusCode::CREATED } else if method == Method::DELETE { StatusCode::NO_CONTENT } else { StatusCode::OK };
|
||||
assert_eq!(result.status(), expected, "{method} {uri}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+33
-7
@@ -1,11 +1,37 @@
|
||||
### Step 1 - handshake WebSocket
|
||||
## Parcours technique RTC
|
||||
|
||||
Le client doit effectuer une connexion HTTP (websocket) sur /rtc/{channel_id}
|
||||
Le server vérifiera avant d'accepter la connexion si :
|
||||
Ce module gère la signalisation WebSocket et le relais audio WebRTC d'un canal. Le WebSocket transporte les descriptions SDP, les candidats ICE et les événements du salon ; les échantillons audio passent par la `PeerConnection`, pas par le WebSocket. Une connexion WebSocket correspond à un `RTCClient` et à une `PeerConnection`.
|
||||
|
||||
- Le channel existe
|
||||
- Vérifiera les permissions sur le canal (check_perm ou si c'est un canal privé)
|
||||
### Entrée et création du client
|
||||
|
||||
### Step 2 - SDP (négociation du type de media)
|
||||
1. `routes/mod.rs` monte `routes/rtc/routes.rs` sous `/ws` : le navigateur ouvre `/ws/rtc/{channel_id}`.
|
||||
2. `routes/rtc/handlers.rs::ws_handler` reçoit le canal et l'utilisateur via `CurrentUser`, cherche le canal en base, puis accepte l'upgrade WebSocket avec `ws_entrypoint_handler`. L'utilisateur doit être connecté et le canal doit exister. Le type du canal et les permissions ne sont **pas encore vérifiés**.
|
||||
3. `rtc/ws_entrypoint.rs::ws_entrypoint_handler` crée une connexion via `RTCManager::new_peer_connection`, puis un `RTCClient` avec le canal, l'utilisateur, un identifiant propre à cette connexion et le gestionnaire partagé des salons (`RTCManager::rooms`). `RTCManager::new`, dans `rtc/mod.rs`, prépare aussi la configuration réseau `rustrtc`, notamment le port UDP ICE.
|
||||
4. L'entrypoint démarre `RTCClient::forward_ice_candidates` avant la négociation, ainsi qu'une tâche de lecture WebSocket (`RTCClient::ws_on_message`) et une tâche d'écriture. `RTCClient::send_response` sérialise les réponses JSON dans le canal lu par cette dernière.
|
||||
|
||||
### Step 3 - ICE (Négociation du flux réseaux)
|
||||
### Négociation initiale : SDP et ICE
|
||||
|
||||
Les actions JSON sont définies dans `rtc/messages.rs`. Le `channel_id` n'est pas répété dans les messages : il est fixé par l'URL du WebSocket.
|
||||
|
||||
1. Le navigateur envoie `sdp-offer`. `RTCClient::ws_on_message` appelle `handle_sdp_offer`, qui lit le type de charge utile Opus proposé et applique l'offre avec `PeerConnection::set_remote_description`.
|
||||
2. Si l'offre contient une piste audio, `handle_sdp_offer` récupère sa piste entrante, réserve la première section audio pour éviter sa réutilisation par un autre locuteur, inscrit le client dans le salon avec `VoiceRoomManager::join`, puis démarre `forward_audio`.
|
||||
3. `create_initial_answer` crée et applique la réponse SDP locale. `opus_sdp` reprend le type de charge utile Opus proposé par le navigateur ; `ws_on_message` renvoie ensuite `answer` et lance `run_room` pour écouter les événements du salon.
|
||||
4. Le navigateur envoie ses messages `ice-candidate` : `handle_ice_candidate` les transmet à `rustrtc`. Un candidat vide marque la fin de collecte et est ignoré. En sens inverse, `forward_ice_candidates` transmet les candidats locaux sous forme de messages `ice-candidate` sur le WebSocket. Un candidat refusé produit un message `error` ; les noms mDNS `.local` peuvent notamment être refusés par `rustrtc`.
|
||||
|
||||
Pour tester depuis Internet, `network.host = "0.0.0.0"` ne définit que l'adresse d'écoute. Sans `network.external_ip`, le serveur utilise par défaut `stun:stun.l.google.com:19302` pour découvrir son IPv4 publique au démarrage et la fournir à ICE ; `network.stun_servers` permet de changer la liste ou de désactiver STUN avec `[]`. Définir `network.external_ip` prend la priorité et évite STUN côté serveur. Rediriger `network.udp_port` en UDP vers le serveur et ouvrir ce port dans le pare-feu : STUN ne crée pas de redirection NAT. Les logs `Configuration ICE du serveur`, `Candidat ICE local envoyé` et `Candidat ICE distant accepté` sont visibles au niveau `info` (par exemple `RUST_LOG=info`). Vérifier que les candidats locaux annoncent une adresse publique **et un port** effectivement joignables ; si le NAT modifie le port ou en cas de NAT symétrique, une redirection adaptée ou un relais TURN peut être nécessaire. Ces logs exposent des adresses IP : éviter de les partager sans les masquer.
|
||||
|
||||
Au démarrage, si `external_ip` est absent, les logs `Diagnostic STUN IPv4` montrent une requête Binding indépendante vers les serveurs configurés, sa réponse ou l'erreur (résolution DNS, délai de 5 secondes, réponse invalide). La première IPv4 publique découverte est injectée dans la configuration ICE avant d'accepter les connexions ; son **port temporaire distinct d'ICE est ignoré** et ICE annonce `network.udp_port`. Si la découverte échoue, la collecte ICE native reste active. Le résultat du diagnostic ne prouve pas que le port ICE est joignable ni que l'IP reste stable après le démarrage : contrôler `Candidat ICE local envoyé` et redémarrer après un changement d'adresse. Pour les erreurs de collecte de `rustrtc`, utiliser `RUST_LOG=info,rustrtc::transports::ice=debug` pendant une connexion.
|
||||
|
||||
### Audio et changements de participants
|
||||
|
||||
`VoiceRoomManager` (`rtc/mod.rs`) maintient un salon par identifiant de canal. `join` crée une source audio propre au client, prévient les autres participants avec `RoomEvent::Joined` et informe le nouvel arrivant des sources existantes. Aucun événement n'inscrit sa propre source comme piste à recevoir : le client ne s'entend pas lui-même.
|
||||
|
||||
`RTCClient::forward_audio` lit les échantillons de la piste entrante et les confie à `VoiceRoomManager::forward`, qui les publie uniquement sur la source de ce client et dans ce canal. Pour chaque `RoomEvent::Joined`, `RTCClient::run_room` crée une piste sortante distincte et une tâche qui alimente cette piste depuis la source correspondante. Le serveur relaie les échantillons Opus sans mixer les voix ; chaque navigateur lit et mixe ses pistes reçues.
|
||||
|
||||
Une nouvelle piste nécessite une renégociation : `run_room` crée une offre SDP, stabilise les identifiants d'extensions RTP avec `stable_extmaps`, envoie `sdp-offer`, puis attend le `sdp-answer` du navigateur. `ws_on_message` applique cette réponse et débloque l'attente. Les renégociations sont traitées l'une après l'autre, avec un délai maximal de dix secondes. Lors d'un `RoomEvent::Left`, `run_room` arrête le transfert de cette source, rend sa piste inactive sans réutiliser sa section SDP, envoie `source-left` au navigateur et renégocie.
|
||||
|
||||
### Déconnexion et responsabilités
|
||||
|
||||
Un message `leave`, une fermeture WebSocket ou l'arrêt de la lecture/écriture termine le traitement dans `ws_entrypoint_handler`. Celui-ci arrête l'autre tâche WebSocket, arrête la tâche ICE et appelle `RTCClient::close`. `close` retire le participant du salon via `VoiceRoomManager::leave` (qui avertit les autres et supprime le salon devenu vide), arrête la tâche de salon et ferme la `PeerConnection`.
|
||||
|
||||
En résumé : `routes/rtc` contrôle l'entrée HTTP, `ws_entrypoint` supervise les tâches et la fermeture, `RTCClient` possède la signalisation et la connexion WebRTC individuelle, et `VoiceRoomManager` distribue les sources audio entre les connexions du même canal. L'ancien module `voice` est distinct de ce parcours RTC.
|
||||
|
||||
+548
-21
@@ -1,9 +1,18 @@
|
||||
use super::messages::VoiceClientMessage;
|
||||
use super::messages::{VoiceClientMessage, VoiceServerMessage};
|
||||
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
|
||||
use crate::models::{channel, user};
|
||||
use axum::extract::ws::Message;
|
||||
use rustrtc::peer_connection::PeerConnection;
|
||||
use rustrtc::media::{self, MediaStreamTrack};
|
||||
use rustrtc::peer_connection::{PeerConnection, RtpCodecParameters};
|
||||
use rustrtc::sdp::{SdpType, SessionDescription};
|
||||
use rustrtc::transports::ice::IceCandidate;
|
||||
use rustrtc::{MediaKind, TransceiverDirection};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use tokio::sync::mpsc;
|
||||
use parking_lot::Mutex;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// Client connecté à un canal RTC.
|
||||
#[derive(Clone)]
|
||||
@@ -12,6 +21,12 @@ pub struct RTCClient {
|
||||
pub channel: Arc<channel::Model>,
|
||||
pub peer_connection: Arc<PeerConnection>,
|
||||
pub websocket_sender: UnboundedSender<Message>,
|
||||
pub room_manager: Arc<VoiceRoomManager>,
|
||||
pub client_id: uuid::Uuid,
|
||||
room_task: Arc<Mutex<Option<tokio::task::JoinHandle<()>>>>,
|
||||
negotiation_done: mpsc::UnboundedSender<()>,
|
||||
negotiation_rx: Arc<Mutex<Option<mpsc::UnboundedReceiver<()>>>>,
|
||||
pending_room: Arc<Mutex<Option<(mpsc::UnboundedReceiver<RoomEvent>, u8, String)>>>,
|
||||
}
|
||||
|
||||
impl RTCClient {
|
||||
@@ -20,41 +35,553 @@ impl RTCClient {
|
||||
channel: channel::Model,
|
||||
peer_connection: Arc<PeerConnection>,
|
||||
websocket_sender: UnboundedSender<Message>,
|
||||
room_manager: Arc<VoiceRoomManager>,
|
||||
) -> Self {
|
||||
let (negotiation_done, negotiation_rx) = mpsc::unbounded_channel();
|
||||
Self {
|
||||
user: Arc::new(user),
|
||||
channel: Arc::new(channel),
|
||||
peer_connection,
|
||||
websocket_sender,
|
||||
room_manager,
|
||||
client_id: uuid::Uuid::new_v4(),
|
||||
room_task: Arc::new(Mutex::new(None)),
|
||||
negotiation_done,
|
||||
negotiation_rx: Arc::new(Mutex::new(Some(negotiation_rx))),
|
||||
pending_room: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ws_on_message(&self, raw_message: Message) {
|
||||
let Message::Text(raw_message) = raw_message else {
|
||||
return;
|
||||
};
|
||||
|
||||
let parsed = match serde_json::from_str::<VoiceClientMessage>(&raw_message) {
|
||||
Ok(message) => message,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
match parsed {
|
||||
VoiceClientMessage::SDPOffer { channel_id, sdp } => {}
|
||||
VoiceClientMessage::IceCandidate {
|
||||
channel_id,
|
||||
candidate,
|
||||
} => {}
|
||||
VoiceClientMessage::Leave { channel_id } => {}
|
||||
/// Retourne `false` quand le client demande à quitter le canal.
|
||||
pub async fn ws_on_message(&self, raw_message: Message) -> bool {
|
||||
match raw_message {
|
||||
Message::Close(_) => false,
|
||||
Message::Text(text) => match serde_json::from_str::<VoiceClientMessage>(&text) {
|
||||
Ok(VoiceClientMessage::Leave) => false,
|
||||
Ok(VoiceClientMessage::SdpAnswer { sdp }) => {
|
||||
match SessionDescription::parse(SdpType::Answer, &sdp) {
|
||||
Ok(answer) => match self.peer_connection.set_remote_description(answer).await {
|
||||
Ok(()) => { let _ = self.negotiation_done.send(()); }
|
||||
Err(error) => warn!(%error, "Réponse de renégociation refusée"),
|
||||
},
|
||||
Err(error) => warn!(%error, "Réponse SDP invalide"),
|
||||
}
|
||||
true
|
||||
}
|
||||
Ok(VoiceClientMessage::SDPOffer { sdp }) => {
|
||||
info!(channel_id = %self.channel.id, "Offre SDP reçue");
|
||||
let response = match self.handle_sdp_offer(&sdp).await {
|
||||
Ok(sdp) => {
|
||||
info!(channel_id = %self.channel.id, "Réponse SDP créée");
|
||||
VoiceServerMessage::Answer { sdp }
|
||||
}
|
||||
Err(error) => {
|
||||
warn!(channel_id = %self.channel.id, %error, "Négociation SDP échouée");
|
||||
VoiceServerMessage::Error { message: error.to_string() }
|
||||
}
|
||||
};
|
||||
self.send_response(response);
|
||||
self.start_room();
|
||||
true
|
||||
}
|
||||
Ok(VoiceClientMessage::IceCandidate { candidate }) => {
|
||||
if candidate.is_empty() {
|
||||
info!(channel_id = %self.channel.id, "Fin de collecte ICE distante");
|
||||
return true;
|
||||
}
|
||||
if let Err(error) = self.handle_ice_candidate(&candidate) {
|
||||
if candidate.split_whitespace().nth(4).is_some_and(|address| address.ends_with(".local")) {
|
||||
warn!(channel_id = %self.channel.id, %error, candidate = %candidate, "Candidat ICE mDNS du navigateur refusé par rustrtc");
|
||||
} else {
|
||||
warn!(channel_id = %self.channel.id, %error, candidate = %candidate, "Candidat ICE distant refusé");
|
||||
}
|
||||
self.send_response(VoiceServerMessage::Error {
|
||||
message: error.to_string(),
|
||||
});
|
||||
} else {
|
||||
info!(channel_id = %self.channel.id, client_id = %self.client_id, candidate = %candidate, "Candidat ICE distant accepté");
|
||||
}
|
||||
true
|
||||
}
|
||||
_ => true,
|
||||
},
|
||||
_ => true,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn ws_send_message(&self, raw_message: Message) {
|
||||
let _ = self.websocket_sender.send(raw_message);
|
||||
async fn handle_sdp_offer(&self, sdp: &str) -> anyhow::Result<String> {
|
||||
let opus_payload_type = sdp.split("m=").find(|section| section.starts_with("audio "))
|
||||
.and_then(|audio| {
|
||||
let formats = audio.lines().next()?.split_whitespace().skip(3).collect::<Vec<_>>();
|
||||
audio.lines().find_map(|line| {
|
||||
let (payload_type, codec) = line.strip_prefix("a=rtpmap:")?.split_once(' ')?;
|
||||
(codec.eq_ignore_ascii_case("opus/48000/2") && formats.contains(&payload_type))
|
||||
.then(|| payload_type.parse::<u8>().ok()).flatten()
|
||||
})
|
||||
});
|
||||
let offer = SessionDescription::parse(SdpType::Offer, sdp)?;
|
||||
self.peer_connection.set_remote_description(offer).await?;
|
||||
if let Some(transceiver) = self.peer_connection.get_transceivers().into_iter()
|
||||
.find(|transceiver| transceiver.kind() == MediaKind::Audio)
|
||||
{
|
||||
if let Some(receiver) = transceiver.receiver() {
|
||||
let incoming = receiver.track();
|
||||
let payload_type = opus_payload_type.ok_or_else(|| anyhow::anyhow!("Opus absent de l'offre audio"))?;
|
||||
// Réserver la m-line du microphone : add_track réutiliserait sinon
|
||||
// cette m-line pour le premier autre participant.
|
||||
let (_, reserved, _) = media::sample_track(media::MediaKind::Audio, 48000);
|
||||
self.peer_connection.add_track(reserved, RtpCodecParameters {
|
||||
payload_type, name: "opus".to_string(), clock_rate: 48000, channels: 2,
|
||||
})?;
|
||||
info!(channel_id = %self.channel.id, "Audio du salon configuré");
|
||||
let channel_id = self.channel.id;
|
||||
let client_id = self.client_id;
|
||||
let room_events = self.room_manager.join_user(self.channel.server_id.expect("canal RTC serveur"), channel_id, client_id, VoiceParticipant {
|
||||
id: self.user.id, username: self.user.username.clone(),
|
||||
});
|
||||
let room_manager = self.room_manager.clone();
|
||||
tokio::spawn(async move { Self::forward_audio(incoming, room_manager, channel_id, client_id).await });
|
||||
let answer = self.create_initial_answer(opus_payload_type).await?;
|
||||
*self.pending_room.lock() = Some((room_events, payload_type, answer.clone()));
|
||||
return Ok(answer);
|
||||
}
|
||||
}
|
||||
self.create_initial_answer(opus_payload_type).await
|
||||
}
|
||||
|
||||
fn start_room(&self) {
|
||||
if let Some((events, payload_type, initial_sdp)) = self.pending_room.lock().take() {
|
||||
let client = self.clone();
|
||||
*self.room_task.lock() = Some(tokio::spawn(async move { client.run_room(events, payload_type, initial_sdp).await }));
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_initial_answer(&self, opus_payload_type: Option<u8>) -> anyhow::Result<String> {
|
||||
let answer = self.peer_connection.create_answer().await?;
|
||||
let sdp = Self::opus_sdp(&answer.to_sdp_string(), opus_payload_type.unwrap_or(111));
|
||||
self.peer_connection.set_local_description(SessionDescription::parse(SdpType::Answer, &sdp)?)?;
|
||||
Ok(sdp)
|
||||
}
|
||||
|
||||
fn opus_sdp(sdp: &str, payload_type: u8) -> String {
|
||||
if payload_type == 111 { return sdp.to_string(); }
|
||||
let mut in_audio = false;
|
||||
sdp.split_inclusive('\n').map(|line| {
|
||||
if line.starts_with("m=") { in_audio = line.starts_with("m=audio "); }
|
||||
if !in_audio { return line.to_string(); }
|
||||
let ending = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
|
||||
let content = line.trim_end_matches(['\r', '\n']);
|
||||
let adjusted = if content.starts_with("m=audio ") {
|
||||
content.split_whitespace().map(|part| if part == "111" { payload_type.to_string() } else { part.to_string() })
|
||||
.collect::<Vec<_>>().join(" ")
|
||||
} else if let Some(rest) = content.strip_prefix("a=rtpmap:111 ") {
|
||||
format!("a=rtpmap:{payload_type} {rest}")
|
||||
} else if let Some(rest) = content.strip_prefix("a=fmtp:111 ") {
|
||||
format!("a=fmtp:{payload_type} {rest}")
|
||||
} else { content.to_string() };
|
||||
format!("{adjusted}{ending}")
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn stable_extmaps(sdp: &str, initial_sdp: &str) -> String {
|
||||
let mut ids = HashMap::<String, u16>::new();
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for line in initial_sdp.lines() {
|
||||
if let Some((id, uri)) = line.strip_prefix("a=extmap:")
|
||||
.and_then(|value| value.split_once(' '))
|
||||
.and_then(|(id, rest)| Some((id.split('/').next()?.parse::<u16>().ok()?, rest.split_whitespace().next()?))) {
|
||||
ids.entry(uri.to_string()).or_insert(id);
|
||||
used.insert(id);
|
||||
}
|
||||
}
|
||||
sdp.split_inclusive('\n').map(|line| {
|
||||
let Some((_, rest)) = line.strip_prefix("a=extmap:").and_then(|value| value.split_once(' ')) else {
|
||||
return line.to_string();
|
||||
};
|
||||
let Some(uri) = rest.split_whitespace().next() else { return line.to_string() };
|
||||
let id = *ids.entry(uri.to_string()).or_insert_with(|| {
|
||||
let free = (1..=14).chain(4096..=4351).find(|id| !used.contains(id)).expect("identifiants RTP épuisés");
|
||||
used.insert(free);
|
||||
free
|
||||
});
|
||||
let ending = if line.ends_with("\r\n") { "\r\n" } else { "\n" };
|
||||
format!("a=extmap:{id} {}{ending}", rest.trim_end_matches(['\r', '\n']))
|
||||
}).collect()
|
||||
}
|
||||
|
||||
async fn run_room(&self, mut events: mpsc::UnboundedReceiver<RoomEvent>, payload_type: u8, initial_sdp: String) {
|
||||
let mut negotiation_rx = self.negotiation_rx.lock().take().expect("une seule tâche de salon");
|
||||
let mut tracks: HashMap<uuid::Uuid, (Arc<rustrtc::peer_connection::RtpTransceiver>, tokio::task::JoinHandle<()>)> = HashMap::new();
|
||||
while let Some(event) = events.recv().await {
|
||||
match event {
|
||||
RoomEvent::Joined(id, mut receiver) => {
|
||||
let (source, outgoing, _) = media::sample_track(media::MediaKind::Audio, 48000);
|
||||
let sender = match self.peer_connection.add_track_with_stream_id(outgoing, id.to_string(), RtpCodecParameters {
|
||||
payload_type, name: "opus".to_string(), clock_rate: 48000, channels: 2,
|
||||
}) {
|
||||
Ok(sender) => sender,
|
||||
Err(error) => { warn!(%error, "Ajout de la piste du salon impossible"); continue; }
|
||||
};
|
||||
let Some(transceiver) = self.peer_connection.get_transceivers().into_iter().find(|t| t.sender().is_some_and(|s| s.ssrc() == sender.ssrc())) else { continue };
|
||||
transceiver.set_direction(TransceiverDirection::SendRecv);
|
||||
let task = tokio::spawn(async move {
|
||||
loop {
|
||||
match receiver.recv().await {
|
||||
Ok(mut sample) => {
|
||||
if let media::MediaSample::Audio(frame) = &mut sample {
|
||||
frame.payload_type = None;
|
||||
frame.sequence_number = None;
|
||||
frame.raw_packet = None;
|
||||
}
|
||||
if source.send(sample).is_err() { break; }
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
});
|
||||
tracks.insert(id, (transceiver, task));
|
||||
}
|
||||
RoomEvent::Left(id) => {
|
||||
if let Some((transceiver, task)) = tracks.remove(&id) {
|
||||
task.abort();
|
||||
// Conserver l'émetteur pour éviter que rustrtc réutilise cette
|
||||
// m-line pour un nouveau participant après son départ.
|
||||
transceiver.set_direction(TransceiverDirection::Inactive);
|
||||
}
|
||||
self.send_response(VoiceServerMessage::SourceLeft { id });
|
||||
}
|
||||
}
|
||||
match self.peer_connection.create_offer().await {
|
||||
Ok(offer) => {
|
||||
let sdp = Self::stable_extmaps(&Self::opus_sdp(&offer.to_sdp_string(), payload_type), &initial_sdp);
|
||||
if let Err(error) = SessionDescription::parse(SdpType::Offer, &sdp)
|
||||
.map_err(anyhow::Error::from)
|
||||
.and_then(|offer| self.peer_connection.set_local_description(offer).map_err(anyhow::Error::from)) {
|
||||
warn!(%error, "Offre du salon impossible"); break;
|
||||
}
|
||||
self.send_response(VoiceServerMessage::SdpOffer { sdp });
|
||||
if tokio::time::timeout(std::time::Duration::from_secs(10), negotiation_rx.recv()).await.ok().flatten().is_none() {
|
||||
warn!("Renégociation du salon expirée"); break;
|
||||
}
|
||||
}
|
||||
Err(error) => { warn!(%error, "Renégociation du salon impossible"); break; }
|
||||
}
|
||||
}
|
||||
for (_, (_, task)) in tracks { task.abort(); }
|
||||
}
|
||||
|
||||
async fn forward_audio(
|
||||
incoming: Arc<media::SampleStreamTrack>,
|
||||
room_manager: Arc<VoiceRoomManager>,
|
||||
channel_id: uuid::Uuid,
|
||||
client_id: uuid::Uuid,
|
||||
) {
|
||||
while let Ok(sample) = incoming.recv().await {
|
||||
room_manager.forward(channel_id, client_id, sample);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_ice_candidate(&self, candidate: &str) -> anyhow::Result<()> {
|
||||
self.peer_connection
|
||||
.add_ice_candidate(IceCandidate::from_sdp(candidate.strip_prefix("candidate:").unwrap_or(candidate))?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Transmet au WebSocket les candidats ICE locaux produits par la connexion WebRTC.
|
||||
/// À démarrer avant l'offre SDP pour ne pas manquer les premiers candidats ;
|
||||
/// la tâche retournée doit être arrêtée à la déconnexion.
|
||||
pub fn forward_ice_candidates(&self) -> tokio::task::JoinHandle<()> {
|
||||
// Chaque connexion WebSocket a son propre abonnement aux candidats locaux.
|
||||
let mut candidates = self.peer_connection.subscribe_ice_candidates();
|
||||
// La tâche doit conserver le client pendant qu'elle attend les candidats.
|
||||
let client = self.clone();
|
||||
tokio::spawn(async move {
|
||||
// recv attend un candidat ; une erreur termine l'écoute.
|
||||
while let Ok(candidate) = candidates.recv().await {
|
||||
// Le candidat est sérialisé pour être envoyé au navigateur via send_response.
|
||||
info!(channel_id = %client.channel.id, client_id = %client.client_id, candidate = %candidate.to_sdp(), "Candidat ICE local envoyé");
|
||||
client.send_response(VoiceServerMessage::IceCandidate {
|
||||
candidate: format!("candidate:{}", candidate.to_sdp()),
|
||||
});
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn send_response(&self, response: VoiceServerMessage) {
|
||||
if let Ok(json) = serde_json::to_string(&response) {
|
||||
let _ = self.websocket_sender.send(Message::Text(json.into()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Ferme proprement la connexion WebRTC.
|
||||
pub fn close(&self) {
|
||||
info!(channel_id = %self.channel.id, "Fermeture de la connexion RTC");
|
||||
self.room_manager.leave(self.channel.id, self.client_id);
|
||||
if let Some(task) = self.room_task.lock().take() { task.abort(); }
|
||||
self.peer_connection.close();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::RTCClient;
|
||||
use crate::models::{channel, user};
|
||||
use axum::extract::ws::Message;
|
||||
use chrono::Utc;
|
||||
use rustrtc::peer_connection::TransceiverDirection;
|
||||
use rustrtc::sdp::{SdpType, SessionDescription};
|
||||
use rustrtc::{MediaKind, PeerConnection, RtcConfigurationBuilder};
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn client() -> (RTCClient, mpsc::UnboundedReceiver<Message>) {
|
||||
let now = Utc::now();
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
(
|
||||
RTCClient::new(
|
||||
user::Model {
|
||||
id: Uuid::new_v4(),
|
||||
username: "test".into(),
|
||||
password: String::new(),
|
||||
pub_key: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
is_superuser: false,
|
||||
},
|
||||
channel::Model {
|
||||
id: Uuid::new_v4(),
|
||||
server_id: Some(Uuid::new_v4()),
|
||||
category_id: None,
|
||||
channel_type: channel::ChannelType::Voice,
|
||||
name: None,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
},
|
||||
Arc::new(PeerConnection::new(
|
||||
RtcConfigurationBuilder::new().build(),
|
||||
)),
|
||||
sender,
|
||||
Arc::new(super::super::VoiceRoomManager::new()),
|
||||
),
|
||||
receiver,
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn leave_and_close_end_connection() {
|
||||
let (client, _) = client();
|
||||
let leave = r#"{"action":"leave"}"#;
|
||||
assert!(!client.ws_on_message(Message::Text(leave.into())).await);
|
||||
assert!(!client.ws_on_message(Message::Close(None)).await);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_messages_keep_connection_open() {
|
||||
let (client, mut receiver) = client();
|
||||
assert!(client.ws_on_message(Message::Text("invalid".into())).await);
|
||||
assert!(client.ws_on_message(Message::Ping(vec![].into())).await);
|
||||
let offer = r#"{"action":"sdp-offer","sdp":"test"}"#;
|
||||
assert!(serde_json::from_str::<super::VoiceClientMessage>(&offer).is_ok());
|
||||
assert!(client.ws_on_message(Message::Text(offer.into())).await);
|
||||
let response = receiver.try_recv().unwrap();
|
||||
let Message::Text(response) = response else {
|
||||
panic!("expected text")
|
||||
};
|
||||
assert_eq!(
|
||||
serde_json::from_str::<serde_json::Value>(&response).unwrap()["action"],
|
||||
"error"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn offer_returns_answer() {
|
||||
let (client, mut receiver) = client();
|
||||
let remote = PeerConnection::new(RtcConfigurationBuilder::new().build());
|
||||
remote.create_data_channel("test", None).unwrap();
|
||||
let offer = remote.create_offer().await.unwrap();
|
||||
let message = serde_json::json!({
|
||||
"action": "sdp-offer",
|
||||
"sdp": offer.to_sdp_string(),
|
||||
});
|
||||
assert!(
|
||||
client
|
||||
.ws_on_message(Message::Text(message.to_string().into()))
|
||||
.await
|
||||
);
|
||||
let Message::Text(response) = receiver.try_recv().unwrap() else {
|
||||
panic!("expected text")
|
||||
};
|
||||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(response["action"], "answer");
|
||||
assert!(response.get("channel_id").is_none());
|
||||
assert!(response["sdp"].as_str().unwrap().starts_with("v=0"));
|
||||
client.close();
|
||||
remote.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn invalid_ice_candidate_returns_error() {
|
||||
let (client, mut receiver) = client();
|
||||
let message = r#"{"action":"ice-candidate","candidate":"invalid"}"#;
|
||||
assert!(client.ws_on_message(Message::Text(message.into())).await);
|
||||
let Message::Text(response) = receiver.try_recv().unwrap() else {
|
||||
panic!("expected text")
|
||||
};
|
||||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(response["action"], "error");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_ice_candidate_is_ignored() {
|
||||
let (client, mut receiver) = client();
|
||||
let message = r#"{"action":"ice-candidate","candidate":""}"#;
|
||||
assert!(client.ws_on_message(Message::Text(message.into())).await);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
client.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audio_offer_prepares_room_without_self_echo() {
|
||||
let (client, mut receiver) = client();
|
||||
let remote = PeerConnection::new(RtcConfigurationBuilder::new().build());
|
||||
remote.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv);
|
||||
let offer = remote.create_offer().await.unwrap();
|
||||
let message = serde_json::json!({"action": "sdp-offer", "sdp": offer.to_sdp_string()});
|
||||
assert!(client.ws_on_message(Message::Text(message.to_string().into())).await);
|
||||
let Message::Text(response) = receiver.try_recv().unwrap() else {
|
||||
panic!("expected text")
|
||||
};
|
||||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(response["action"], "answer", "{response}");
|
||||
let audio = response["sdp"].as_str().unwrap().split("m=")
|
||||
.find(|section| section.starts_with("audio ")).unwrap();
|
||||
assert!(audio.lines().any(|line| line == "a=sendrecv"), "{audio}");
|
||||
assert!(client.peer_connection.get_transceivers()[0].sender().is_some());
|
||||
client.close();
|
||||
remote.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audio_answer_uses_offered_opus_payload_type() {
|
||||
let (client, mut receiver) = client();
|
||||
let remote = PeerConnection::new(RtcConfigurationBuilder::new().build());
|
||||
remote.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv);
|
||||
let offer = remote.create_offer().await.unwrap().to_sdp_string().replace("111", "109");
|
||||
let message = serde_json::json!({"action": "sdp-offer", "sdp": offer});
|
||||
client.ws_on_message(Message::Text(message.to_string().into())).await;
|
||||
let Message::Text(response) = receiver.try_recv().unwrap() else { panic!("expected text") };
|
||||
let response: serde_json::Value = serde_json::from_str(&response).unwrap();
|
||||
assert_eq!(response["action"], "answer", "{response}");
|
||||
let audio = response["sdp"].as_str().unwrap().split("m=")
|
||||
.find(|section| section.starts_with("audio ")).unwrap();
|
||||
assert!(audio.lines().any(|line| line == "a=rtpmap:109 opus/48000/2"), "{audio}");
|
||||
client.close();
|
||||
remote.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn joining_listener_gets_a_distinct_audio_offer() {
|
||||
let (client, mut messages) = client();
|
||||
let remote = PeerConnection::new(RtcConfigurationBuilder::new().build());
|
||||
remote.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv);
|
||||
let offer = remote.create_offer().await.unwrap().to_sdp_string().replace("111", "109");
|
||||
remote.set_local_description(rustrtc::sdp::SessionDescription::parse(rustrtc::sdp::SdpType::Offer, &offer).unwrap()).unwrap();
|
||||
let message = serde_json::json!({"action": "sdp-offer", "sdp": offer});
|
||||
client.ws_on_message(Message::Text(message.to_string().into())).await;
|
||||
let Message::Text(answer) = messages.recv().await.unwrap() else { panic!("réponse attendue") };
|
||||
let answer: serde_json::Value = serde_json::from_str(&answer).unwrap();
|
||||
remote.set_remote_description(rustrtc::sdp::SessionDescription::parse(
|
||||
rustrtc::sdp::SdpType::Answer, answer["sdp"].as_str().unwrap(),
|
||||
).unwrap()).await.unwrap();
|
||||
let other = Uuid::new_v4();
|
||||
let _other_events = client.room_manager.join(client.channel.id, other);
|
||||
let Message::Text(offer) = tokio::time::timeout(std::time::Duration::from_secs(2), messages.recv()).await.unwrap().unwrap() else { panic!("offre attendue") };
|
||||
let offer: serde_json::Value = serde_json::from_str(&offer).unwrap();
|
||||
assert_eq!(offer["action"], "sdp-offer", "{offer}");
|
||||
assert_eq!(offer["sdp"].as_str().unwrap().matches("m=audio ").count(), 2);
|
||||
let outgoing = offer["sdp"].as_str().unwrap().split("m=").filter(|section| section.starts_with("audio ")).nth(1).unwrap();
|
||||
assert!(outgoing.lines().next().unwrap().ends_with(" 109"), "{outgoing}");
|
||||
let initial_mid = answer["sdp"].as_str().unwrap().lines().find(|line| line.contains("urn:ietf:params:rtp-hdrext:sdes:mid"));
|
||||
let next_mid = offer["sdp"].as_str().unwrap().lines().find(|line| line.contains("urn:ietf:params:rtp-hdrext:sdes:mid"));
|
||||
assert_eq!(initial_mid, next_mid);
|
||||
client.close();
|
||||
remote.close();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn returning_participant_gets_a_new_audio_section() {
|
||||
let (client, mut messages) = client();
|
||||
let remote = PeerConnection::new(RtcConfigurationBuilder::new().build());
|
||||
remote.add_transceiver(MediaKind::Audio, TransceiverDirection::SendRecv);
|
||||
let offer = remote.create_offer().await.unwrap();
|
||||
remote.set_local_description(offer.clone()).unwrap();
|
||||
let message = serde_json::json!({"action": "sdp-offer", "sdp": offer.to_sdp_string()});
|
||||
client.ws_on_message(Message::Text(message.to_string().into())).await;
|
||||
let Message::Text(answer) = messages.recv().await.unwrap() else { panic!("réponse attendue") };
|
||||
let answer: serde_json::Value = serde_json::from_str(&answer).unwrap();
|
||||
remote.set_remote_description(SessionDescription::parse(SdpType::Answer, answer["sdp"].as_str().unwrap()).unwrap()).await.unwrap();
|
||||
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
let _first_events = client.room_manager.join(client.channel.id, first);
|
||||
for (step, expected_sections) in [2, 2, 3].into_iter().enumerate() {
|
||||
if step == 1 {
|
||||
let Message::Text(left) = messages.recv().await.unwrap() else { panic!("départ attendu") };
|
||||
assert_eq!(serde_json::from_str::<serde_json::Value>(&left).unwrap()["action"], "source-left");
|
||||
}
|
||||
let Message::Text(message) = tokio::time::timeout(std::time::Duration::from_secs(2), messages.recv()).await.unwrap().unwrap() else { panic!("message attendu") };
|
||||
let message: serde_json::Value = serde_json::from_str(&message).unwrap();
|
||||
assert_eq!(message["action"], "sdp-offer", "{message}");
|
||||
assert_eq!(message["sdp"].as_str().unwrap().matches("m=audio ").count(), expected_sections);
|
||||
let offer = SessionDescription::parse(SdpType::Offer, message["sdp"].as_str().unwrap()).unwrap();
|
||||
remote.set_remote_description(offer).await.unwrap();
|
||||
let answer = remote.create_answer().await.unwrap();
|
||||
remote.set_local_description(answer.clone()).unwrap();
|
||||
let response = serde_json::json!({"action": "sdp-answer", "sdp": answer.to_sdp_string()});
|
||||
client.ws_on_message(Message::Text(response.to_string().into())).await;
|
||||
if step == 0 {
|
||||
client.room_manager.leave(client.channel.id, first);
|
||||
} else if step == 1 {
|
||||
let _second_events = client.room_manager.join(client.channel.id, second);
|
||||
}
|
||||
}
|
||||
client.close();
|
||||
remote.close();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renegotiation_keeps_extension_ids_across_audio_sections() {
|
||||
let initial = "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 109\r\na=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
|
||||
let offer = "v=0\r\nm=audio 9 UDP/TLS/RTP/SAVPF 109\r\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\nm=audio 9 UDP/TLS/RTP/SAVPF 109\r\na=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\r\na=extmap:4 urn:ietf:params:rtp-hdrext:sdes:mid\r\n";
|
||||
let fixed = RTCClient::stable_extmaps(offer, initial);
|
||||
assert_eq!(fixed.matches("a=extmap:3 urn:ietf:params:rtp-hdrext:sdes:mid").count(), 2);
|
||||
assert!(!fixed.contains("a=extmap:3 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time"));
|
||||
assert_eq!(fixed.matches("a=extmap:1 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time").count(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn valid_ice_candidate_is_accepted() {
|
||||
let (client, mut receiver) = client();
|
||||
let message = serde_json::json!({
|
||||
"action": "ice-candidate",
|
||||
"candidate": "candidate:1 1 udp 2130706431 127.0.0.1 12345 typ host",
|
||||
});
|
||||
assert!(
|
||||
client
|
||||
.ws_on_message(Message::Text(message.to_string().into()))
|
||||
.await
|
||||
);
|
||||
assert!(receiver.try_recv().is_err());
|
||||
client.close();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_candidate_uses_browser_format() {
|
||||
let candidate = rustrtc::transports::ice::IceCandidate::from_sdp(
|
||||
"1 1 udp 2130706431 127.0.0.1 12345 typ host",
|
||||
).unwrap();
|
||||
assert!(format!("candidate:{}", candidate.to_sdp()).starts_with("candidate:1 1 udp"));
|
||||
}
|
||||
}
|
||||
|
||||
+9
-6
@@ -1,18 +1,21 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "kebab-case")]
|
||||
pub enum VoiceClientMessage {
|
||||
SDPOffer { channel_id: Uuid, sdp: String },
|
||||
IceCandidate { channel_id: Uuid, candidate: String },
|
||||
Leave { channel_id: Uuid },
|
||||
#[serde(rename = "sdp-offer")]
|
||||
SDPOffer { sdp: String },
|
||||
SdpAnswer { sdp: String },
|
||||
IceCandidate { candidate: String },
|
||||
Leave,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(tag = "action", rename_all = "kebab-case")]
|
||||
pub enum VoiceServerMessage {
|
||||
Answer { channel_id: Uuid, sdp: String },
|
||||
IceCandidate { channel_id: Uuid, candidate: String },
|
||||
Answer { sdp: String },
|
||||
SdpOffer { sdp: String },
|
||||
SourceLeft { id: uuid::Uuid },
|
||||
IceCandidate { candidate: String },
|
||||
Error { message: String },
|
||||
}
|
||||
|
||||
+290
-27
@@ -1,18 +1,22 @@
|
||||
mod client;
|
||||
mod messages;
|
||||
mod metrics;
|
||||
mod stun_diagnostic;
|
||||
pub mod ws_entrypoint;
|
||||
|
||||
use crate::config::NetworkConfig;
|
||||
use crate::models::channel;
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use crate::repositories::Repositories;
|
||||
use crate::rtc::client::RTCClient;
|
||||
use crate::services::Services;
|
||||
use event_bus::EventBus;
|
||||
use rustrtc::{PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
||||
use rustrtc::{IceServer, PeerConnection, RtcConfiguration, RtcConfigurationBuilder};
|
||||
use rustrtc::media::MediaSample;
|
||||
use parking_lot::Mutex;
|
||||
use std::collections::HashMap;
|
||||
use std::fmt;
|
||||
use std::net::Ipv4Addr;
|
||||
use std::sync::Arc;
|
||||
use serde::Serialize;
|
||||
// 1. Client crée une RTCPeerConnection
|
||||
// 2. Client crée une SDP offer
|
||||
// 3. Client envoie l'offer via WebSocket
|
||||
@@ -24,10 +28,27 @@ use std::sync::Arc;
|
||||
// 9. ICE sélectionne un chemin réseau
|
||||
// 10. La connexion WebRTC devient active
|
||||
|
||||
pub struct VoiceRoom {}
|
||||
pub struct VoiceRoom {
|
||||
participants: HashMap<uuid::Uuid, tokio::sync::mpsc::UnboundedSender<RoomEvent>>,
|
||||
sources: HashMap<uuid::Uuid, tokio::sync::broadcast::Sender<MediaSample>>,
|
||||
users: HashMap<uuid::Uuid, VoiceParticipant>,
|
||||
server_id: Option<uuid::Uuid>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, serde::Deserialize, utoipa::ToSchema)]
|
||||
pub struct VoiceParticipant {
|
||||
pub id: uuid::Uuid,
|
||||
pub username: String,
|
||||
}
|
||||
|
||||
pub enum RoomEvent {
|
||||
Joined(uuid::Uuid, tokio::sync::broadcast::Receiver<MediaSample>),
|
||||
Left(uuid::Uuid),
|
||||
}
|
||||
|
||||
pub struct VoiceRoomManager {
|
||||
rooms: HashMap<channel::Model, VoiceRoom>,
|
||||
rooms: Mutex<HashMap<uuid::Uuid, VoiceRoom>>,
|
||||
event_bus: Option<Arc<EventBus>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -37,8 +58,7 @@ pub struct RTCManager {
|
||||
pub services: Arc<Services>,
|
||||
pub event_bus: Arc<EventBus>,
|
||||
|
||||
//
|
||||
rooms: Arc<VoiceRoomManager>,
|
||||
pub(crate) rooms: Arc<VoiceRoomManager>,
|
||||
}
|
||||
|
||||
impl fmt::Debug for RTCManager {
|
||||
@@ -52,32 +72,137 @@ impl fmt::Debug for RTCManager {
|
||||
|
||||
impl VoiceRoom {
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
Self { participants: HashMap::new(), sources: HashMap::new(), users: HashMap::new(), server_id: None }
|
||||
}
|
||||
}
|
||||
|
||||
impl VoiceRoomManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
rooms: HashMap::new(),
|
||||
rooms: Mutex::new(HashMap::new()),
|
||||
event_bus: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_event_bus(event_bus: Arc<EventBus>) -> Self {
|
||||
Self { rooms: Mutex::new(HashMap::new()), event_bus: Some(event_bus) }
|
||||
}
|
||||
|
||||
pub fn join(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
self.join_inner(channel_id, client_id, None)
|
||||
}
|
||||
|
||||
pub fn join_user(&self, server_id: uuid::Uuid, channel_id: uuid::Uuid, client_id: uuid::Uuid, user: VoiceParticipant) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
self.join_inner(channel_id, client_id, Some((server_id, user)))
|
||||
}
|
||||
|
||||
fn join_inner(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid, user: Option<(uuid::Uuid, VoiceParticipant)>) -> tokio::sync::mpsc::UnboundedReceiver<RoomEvent> {
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let mut rooms = self.rooms.lock();
|
||||
let room = rooms.entry(channel_id).or_insert_with(VoiceRoom::new);
|
||||
let (source, _) = tokio::sync::broadcast::channel(120);
|
||||
for (id, existing) in &room.sources {
|
||||
let _ = tx.send(RoomEvent::Joined(*id, existing.subscribe()));
|
||||
}
|
||||
for participant in room.participants.values() {
|
||||
let _ = participant.send(RoomEvent::Joined(client_id, source.subscribe()));
|
||||
}
|
||||
room.sources.insert(client_id, source);
|
||||
room.participants.insert(client_id, tx);
|
||||
if let Some((server_id, user)) = user {
|
||||
room.server_id = Some(server_id);
|
||||
let first = !room.users.values().any(|existing| existing.id == user.id);
|
||||
room.users.insert(client_id, user.clone());
|
||||
if first { if let Some(bus) = &self.event_bus { bus.emit(VoicePresenceEvent { server_id, channel_id, user, joined: true }); } }
|
||||
}
|
||||
rx
|
||||
}
|
||||
|
||||
pub fn participants(&self, channel_id: uuid::Uuid) -> Vec<VoiceParticipant> {
|
||||
let rooms = self.rooms.lock();
|
||||
let mut users: Vec<_> = rooms.get(&channel_id).into_iter()
|
||||
.flat_map(|room| room.users.values()).cloned().collect();
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
users.retain(|user| seen.insert(user.id));
|
||||
users.sort_by(|a, b| a.username.cmp(&b.username).then(a.id.cmp(&b.id)));
|
||||
users
|
||||
}
|
||||
|
||||
pub fn forward(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid, sample: MediaSample) {
|
||||
if let Some(source) = self.rooms.lock().get(&channel_id).and_then(|room| room.sources.get(&client_id).cloned()) {
|
||||
let _ = source.send(sample);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn leave(&self, channel_id: uuid::Uuid, client_id: uuid::Uuid) {
|
||||
let mut rooms = self.rooms.lock();
|
||||
if let Some(room) = rooms.get_mut(&channel_id) {
|
||||
room.participants.remove(&client_id);
|
||||
room.sources.remove(&client_id);
|
||||
let removed = room.users.remove(&client_id);
|
||||
if let (Some(user), Some(server_id), Some(bus)) = (removed, room.server_id, &self.event_bus) {
|
||||
if !room.users.values().any(|existing| existing.id == user.id) {
|
||||
bus.emit(VoicePresenceEvent { server_id, channel_id, user, joined: false });
|
||||
}
|
||||
}
|
||||
for participant in room.participants.values() {
|
||||
let _ = participant.send(RoomEvent::Left(client_id));
|
||||
}
|
||||
if room.participants.is_empty() {
|
||||
rooms.remove(&channel_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RTCManager {
|
||||
pub fn new(
|
||||
fn ice_config(network: &NetworkConfig) -> RtcConfiguration {
|
||||
Self::ice_config_with_discovered_ip(network, None)
|
||||
}
|
||||
|
||||
fn ice_config_with_discovered_ip(network: &NetworkConfig, discovered_ip: Option<Ipv4Addr>) -> RtcConfiguration {
|
||||
let mut builder = RtcConfigurationBuilder::new()
|
||||
.ice_udp_mux(true)
|
||||
.ice_udp_mux_port(network.udp_port)
|
||||
.bind_ip(network.host.to_string());
|
||||
if let Some(ip) = network.external_ip.or(discovered_ip) {
|
||||
builder = builder.external_ip(ip.to_string());
|
||||
} else {
|
||||
for url in &network.stun_servers {
|
||||
builder = builder.ice_server(IceServer::new(vec![url.clone()]));
|
||||
}
|
||||
}
|
||||
builder.build()
|
||||
}
|
||||
|
||||
pub async fn new(
|
||||
network: &NetworkConfig,
|
||||
repositories: Arc<Repositories>,
|
||||
services: Arc<Services>,
|
||||
event_bus: Arc<EventBus>,
|
||||
) -> Self {
|
||||
let builder = RtcConfigurationBuilder::new()
|
||||
.ice_udp_mux(true)
|
||||
.ice_udp_mux_port(network.udp_port)
|
||||
.bind_ip(network.host.to_string());
|
||||
let config = builder.build();
|
||||
let mut discovered_ip = None;
|
||||
if network.external_ip.is_none() {
|
||||
for url in &network.stun_servers {
|
||||
match stun_diagnostic::probe(url, network.host).await {
|
||||
Ok(mapped) => {
|
||||
let std::net::IpAddr::V4(ip) = mapped.ip() else { unreachable!() };
|
||||
if ip.is_unspecified() || ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_multicast() || ip.is_broadcast() {
|
||||
tracing::warn!(%url, %mapped, "Adresse STUN IPv4 non publique ignorée");
|
||||
continue;
|
||||
}
|
||||
tracing::info!(%url, %mapped, udp_port = network.udp_port, "IPv4 STUN injectée dans ICE ; port temporaire ignoré");
|
||||
discovered_ip = Some(ip);
|
||||
break;
|
||||
}
|
||||
Err(error) => tracing::warn!(%url, %error, "Diagnostic STUN IPv4 échoué"),
|
||||
}
|
||||
}
|
||||
}
|
||||
let config = Self::ice_config_with_discovered_ip(network, discovered_ip);
|
||||
tracing::info!(bind_ip = %network.host, udp_port = network.udp_port, external_ip = ?config.external_ip, stun_servers = ?config.ice_servers.iter().flat_map(|server| &server.urls).collect::<Vec<_>>(), "Configuration ICE du serveur");
|
||||
|
||||
let rooms = Arc::new(VoiceRoomManager::new());
|
||||
let rooms = Arc::new(VoiceRoomManager::with_event_bus(event_bus.clone()));
|
||||
|
||||
Self {
|
||||
config,
|
||||
@@ -88,17 +213,155 @@ impl RTCManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Session Description Protocol - Permet de se mettre d'accord sur les paramètres media
|
||||
pub async fn handle_sdp_offer(&self, rtc_client: &RTCClient, offer_sdp: String) {
|
||||
let pc = rtc_client.peer_connection.clone();
|
||||
}
|
||||
|
||||
/// Interactive Connectivity Establishment - Permet de négocier un chemin réseau
|
||||
pub async fn handle_ice_candidate(&self) {}
|
||||
|
||||
pub fn new_peer_connection(&self) -> Arc<PeerConnection> {
|
||||
let pc = Arc::new(PeerConnection::new(self.config.clone()));
|
||||
// todo : some logging ?
|
||||
pc
|
||||
Arc::new(PeerConnection::new(self.config.clone()))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod ice_config_tests {
|
||||
use super::RTCManager;
|
||||
use crate::config::{AppConfig, DEFAULT_CONFIG_TOML};
|
||||
use std::net::Ipv4Addr;
|
||||
|
||||
#[test]
|
||||
fn stun_is_used_only_without_manual_external_ip() {
|
||||
let mut network = toml::from_str::<AppConfig>(DEFAULT_CONFIG_TOML).unwrap().network;
|
||||
let config = RTCManager::ice_config(&network);
|
||||
assert_eq!(config.ice_servers[0].urls, network.stun_servers);
|
||||
assert!(config.ice_udp_mux);
|
||||
assert_eq!(config.ice_udp_mux_port, Some(network.udp_port));
|
||||
|
||||
network.external_ip = Some(Ipv4Addr::new(203, 0, 113, 1));
|
||||
let config = RTCManager::ice_config(&network);
|
||||
assert!(config.ice_servers.is_empty());
|
||||
assert_eq!(config.external_ip.as_deref(), Some("203.0.113.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovered_ipv4_is_used_without_advertising_the_probe_port() {
|
||||
let network = toml::from_str::<AppConfig>(DEFAULT_CONFIG_TOML).unwrap().network;
|
||||
let config = RTCManager::ice_config_with_discovered_ip(&network, Some(Ipv4Addr::new(82, 64, 205, 121)));
|
||||
assert_eq!(config.external_ip.as_deref(), Some("82.64.205.121"));
|
||||
assert_eq!(config.ice_udp_mux_port, Some(network.udp_port));
|
||||
assert!(config.ice_servers.is_empty());
|
||||
|
||||
let fallback = RTCManager::ice_config_with_discovered_ip(&network, None);
|
||||
assert!(fallback.external_ip.is_none());
|
||||
assert!(!fallback.ice_servers.is_empty());
|
||||
|
||||
let mut manual = network;
|
||||
manual.external_ip = Some(Ipv4Addr::new(203, 0, 113, 1));
|
||||
let config = RTCManager::ice_config_with_discovered_ip(&manual, Some(Ipv4Addr::new(82, 64, 205, 121)));
|
||||
assert_eq!(config.external_ip.as_deref(), Some("203.0.113.1"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod room_tests {
|
||||
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
|
||||
use crate::domain::events::voice_presence::VoicePresenceEvent;
|
||||
use event_bus::EventBus;
|
||||
use rustrtc::media::{AudioFrame, MediaSample};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[test]
|
||||
fn presence_is_scoped_deduplicated_and_removed_per_connection() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let channel = Uuid::new_v4();
|
||||
let other = Uuid::new_v4();
|
||||
let user = VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
let server = Uuid::new_v4();
|
||||
rooms.join_user(server, channel, first, user.clone());
|
||||
rooms.join_user(server, channel, second, user.clone());
|
||||
rooms.join_user(server, other, Uuid::new_v4(), user.clone());
|
||||
assert_eq!(rooms.participants(channel), vec![user.clone()]);
|
||||
rooms.leave(channel, first);
|
||||
assert_eq!(rooms.participants(channel), vec![user.clone()]);
|
||||
rooms.leave(channel, second);
|
||||
assert!(rooms.participants(channel).is_empty());
|
||||
assert_eq!(rooms.participants(other), vec![user]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn presence_events_only_on_first_join_and_last_leave() {
|
||||
let bus = Arc::new(EventBus::new());
|
||||
let mut events = bus.subscribe::<VoicePresenceEvent>();
|
||||
let rooms = VoiceRoomManager::with_event_bus(bus);
|
||||
let server_id = Uuid::new_v4();
|
||||
let channel_id = Uuid::new_v4();
|
||||
let user = VoiceParticipant { id: Uuid::new_v4(), username: "alice".into() };
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
rooms.join_user(server_id, channel_id, first, user.clone());
|
||||
let joined = events.try_recv().unwrap();
|
||||
assert!(joined.joined);
|
||||
assert_eq!(serde_json::to_value(&joined).unwrap(), serde_json::json!({
|
||||
"server_id": server_id, "channel_id": channel_id,
|
||||
"user": { "id": user.id, "username": "alice" }
|
||||
}));
|
||||
rooms.join_user(server_id, channel_id, second, user.clone());
|
||||
rooms.leave(channel_id, first);
|
||||
assert!(events.try_recv().is_err());
|
||||
rooms.leave(channel_id, second);
|
||||
let left = events.try_recv().unwrap();
|
||||
assert!(!left.joined);
|
||||
assert_eq!(left.user, user);
|
||||
assert!(events.try_recv().is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn audio_reaches_only_other_clients_in_same_room() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let channel = Uuid::new_v4();
|
||||
let sender = Uuid::new_v4();
|
||||
let listener = Uuid::new_v4();
|
||||
let mut own = rooms.join(channel, sender);
|
||||
let mut other = rooms.join(channel, listener);
|
||||
let mut separate = rooms.join(Uuid::new_v4(), Uuid::new_v4());
|
||||
assert!(matches!(own.try_recv(), Ok(RoomEvent::Joined(id, _)) if id == listener));
|
||||
let mut incoming = match other.try_recv().unwrap() { RoomEvent::Joined(id, receiver) if id == sender => receiver, _ => panic!("mauvaise source") };
|
||||
rooms.forward(channel, sender, MediaSample::Audio(AudioFrame::default()));
|
||||
assert!(matches!(incoming.try_recv(), Ok(MediaSample::Audio(_))));
|
||||
assert!(own.try_recv().is_err());
|
||||
assert!(separate.try_recv().is_err());
|
||||
rooms.leave(channel, listener);
|
||||
rooms.forward(channel, sender, MediaSample::Audio(AudioFrame::default()));
|
||||
assert!(other.try_recv().is_err());
|
||||
rooms.leave(channel, sender);
|
||||
assert!(!rooms.rooms.lock().contains_key(&channel));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn simultaneous_sources_remain_separate() {
|
||||
let rooms = VoiceRoomManager::new();
|
||||
let channel = Uuid::new_v4();
|
||||
let first = Uuid::new_v4();
|
||||
let second = Uuid::new_v4();
|
||||
let listener = Uuid::new_v4();
|
||||
let mut first_events = rooms.join(channel, first);
|
||||
let mut second_events = rooms.join(channel, second);
|
||||
let mut listener_events = rooms.join(channel, listener);
|
||||
let mut sources = std::collections::HashMap::new();
|
||||
for _ in 0..2 {
|
||||
if let RoomEvent::Joined(id, rx) = listener_events.try_recv().unwrap() {
|
||||
sources.insert(id, rx);
|
||||
}
|
||||
}
|
||||
let mut first_audio = sources.remove(&first).unwrap();
|
||||
let mut second_audio = sources.remove(&second).unwrap();
|
||||
rooms.forward(channel, first, MediaSample::Audio(AudioFrame::default()));
|
||||
rooms.forward(channel, second, MediaSample::Audio(AudioFrame::default()));
|
||||
assert!(matches!(first_audio.try_recv(), Ok(MediaSample::Audio(_))));
|
||||
assert!(matches!(second_audio.try_recv(), Ok(MediaSample::Audio(_))));
|
||||
assert!(first_audio.try_recv().is_err());
|
||||
assert!(second_audio.try_recv().is_err());
|
||||
assert!(matches!(first_events.try_recv(), Ok(RoomEvent::Joined(id, _)) if id == second));
|
||||
assert!(matches!(first_events.try_recv(), Ok(RoomEvent::Joined(id, _)) if id == listener));
|
||||
assert!(matches!(second_events.try_recv(), Ok(RoomEvent::Joined(id, _)) if id == first));
|
||||
assert!(matches!(second_events.try_recv(), Ok(RoomEvent::Joined(id, _)) if id == listener));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
use std::net::{IpAddr, Ipv4Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
use tokio::net::{UdpSocket, lookup_host};
|
||||
use tokio::time::timeout;
|
||||
|
||||
pub(super) async fn probe(url: &str, bind_ip: Ipv4Addr) -> anyhow::Result<SocketAddr> {
|
||||
let endpoint = url.strip_prefix("stun:").ok_or_else(|| anyhow::anyhow!("URL STUN non prise en charge"))?;
|
||||
let endpoint = endpoint.split('?').next().unwrap_or(endpoint);
|
||||
let mut addresses = lookup_host(endpoint).await?;
|
||||
let server = addresses.find(SocketAddr::is_ipv4).ok_or_else(|| anyhow::anyhow!("aucune adresse IPv4 pour le serveur STUN"))?;
|
||||
let socket = UdpSocket::bind((bind_ip, 0)).await?;
|
||||
let local = socket.local_addr()?;
|
||||
let transaction = *uuid::Uuid::new_v4().as_bytes();
|
||||
let mut request = [0u8; 20];
|
||||
request[..2].copy_from_slice(&0x0001u16.to_be_bytes());
|
||||
request[4..8].copy_from_slice(&0x2112a442u32.to_be_bytes());
|
||||
request[8..20].copy_from_slice(&transaction[..12]);
|
||||
tracing::info!(%server, %local, "Diagnostic STUN IPv4 : requête Binding envoyée (socket distinct d’ICE)");
|
||||
socket.send_to(&request, server).await?;
|
||||
let mut response = [0u8; 1500];
|
||||
let (length, sender) = timeout(Duration::from_secs(5), socket.recv_from(&mut response)).await??;
|
||||
anyhow::ensure!(sender == server, "réponse STUN d'une autre adresse : {sender}");
|
||||
let mapped = mapped_address(&response[..length], &transaction[..12])?;
|
||||
tracing::info!(%server, %local, %mapped, "Diagnostic STUN IPv4 : réponse Binding reçue");
|
||||
Ok(mapped)
|
||||
}
|
||||
|
||||
fn mapped_address(response: &[u8], transaction: &[u8]) -> anyhow::Result<SocketAddr> {
|
||||
anyhow::ensure!(response.len() >= 20 && response[0..2] == [0x01, 0x01]
|
||||
&& response[4..8] == 0x2112a442u32.to_be_bytes()
|
||||
&& response[8..20] == *transaction, "réponse STUN invalide ou transaction différente");
|
||||
let size = u16::from_be_bytes([response[2], response[3]]) as usize;
|
||||
anyhow::ensure!(response.len() >= 20 + size, "réponse STUN tronquée");
|
||||
let mut offset = 20;
|
||||
while offset + 4 <= 20 + size {
|
||||
let kind = u16::from_be_bytes([response[offset], response[offset + 1]]);
|
||||
let len = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) as usize;
|
||||
offset += 4;
|
||||
anyhow::ensure!(offset + len <= 20 + size, "attribut STUN tronqué");
|
||||
if kind == 0x0020 && len >= 8 && response[offset + 1] == 1 {
|
||||
let port = u16::from_be_bytes([response[offset + 2], response[offset + 3]]) ^ 0x2112;
|
||||
let cookie = 0x2112a442u32.to_be_bytes();
|
||||
let ip = Ipv4Addr::from(std::array::from_fn(|i| response[offset + 4 + i] ^ cookie[i]));
|
||||
return Ok(SocketAddr::new(IpAddr::V4(ip), port));
|
||||
}
|
||||
offset += (len + 3) & !3;
|
||||
}
|
||||
anyhow::bail!("aucune adresse IPv4 XOR-MAPPED-ADDRESS dans la réponse STUN")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::mapped_address;
|
||||
|
||||
#[test]
|
||||
fn decodes_ipv4_and_rejects_unrelated_response() {
|
||||
let transaction = [7; 12];
|
||||
let mut response = vec![0x01, 0x01, 0, 12, 0x21, 0x12, 0xa4, 0x42];
|
||||
response.extend(transaction);
|
||||
response.extend([0, 0x20, 0, 8, 0, 1, 0x3f, 0x52, 0x6a, 0x22, 0xa5, 0x43]);
|
||||
assert_eq!(mapped_address(&response, &transaction).unwrap().to_string(), "75.48.1.1:7744");
|
||||
assert!(mapped_address(&response, &[8; 12]).is_err());
|
||||
assert!(mapped_address(&response[..25], &transaction).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<!doctype html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Test RTC</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Test RTC (un client)</h1>
|
||||
<p>Ouvrir ce fichier dans un navigateur. Un canal existant et un JWT de connexion sont nécessaires. Ne partage pas le JWT ni les logs contenant l'URL du WebSocket.</p>
|
||||
<label>Serveur <input id="server" value="http://localhost:8080"></label><br>
|
||||
<label>Channel ID <input id="channel" size="40"></label><br>
|
||||
<label>JWT <input id="token" type="password" size="60"></label><br>
|
||||
<button id="connect">Connecter</button>
|
||||
<button id="disconnect" disabled>Quitter</button>
|
||||
<pre id="log"></pre>
|
||||
<script>
|
||||
const log = (message) => {
|
||||
document.querySelector('#log').textContent += `${new Date().toLocaleTimeString()} ${message}\n`;
|
||||
};
|
||||
const connectButton = document.querySelector('#connect');
|
||||
const disconnectButton = document.querySelector('#disconnect');
|
||||
let socket;
|
||||
let peer;
|
||||
let pendingCandidates = [];
|
||||
|
||||
function disconnect() {
|
||||
if (socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ action: 'leave' }));
|
||||
socket.close();
|
||||
}
|
||||
peer?.close();
|
||||
peer = null;
|
||||
socket = null;
|
||||
pendingCandidates = [];
|
||||
connectButton.disabled = false;
|
||||
disconnectButton.disabled = true;
|
||||
}
|
||||
|
||||
connectButton.onclick = () => {
|
||||
const channel = document.querySelector('#channel').value.trim();
|
||||
const token = document.querySelector('#token').value.trim();
|
||||
if (!channel || !token) {
|
||||
log('Renseigner le canal et le JWT.');
|
||||
return;
|
||||
}
|
||||
let url;
|
||||
try {
|
||||
url = new URL(document.querySelector('#server').value);
|
||||
url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
url.pathname = `/ws/rtc/${encodeURIComponent(channel)}`;
|
||||
url.search = new URLSearchParams({ token }).toString();
|
||||
peer = new RTCPeerConnection();
|
||||
peer.createDataChannel('test'); // Produit une offre SDP sans demander l'accès au microphone.
|
||||
socket = new WebSocket(url);
|
||||
} catch (error) {
|
||||
log(`Configuration invalide : ${error.message}`);
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
connectButton.disabled = true;
|
||||
disconnectButton.disabled = false;
|
||||
peer.onicecandidate = ({ candidate }) => {
|
||||
if (candidate && socket?.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ action: 'ice-candidate', candidate: candidate.candidate }));
|
||||
log('Candidat ICE local envoyé');
|
||||
}
|
||||
};
|
||||
peer.oniceconnectionstatechange = () => log(`ICE : ${peer.iceConnectionState}`);
|
||||
peer.onconnectionstatechange = () => log(`Connexion : ${peer.connectionState}`);
|
||||
socket.onopen = async () => {
|
||||
log('WebSocket ouvert');
|
||||
try {
|
||||
const offer = await peer.createOffer();
|
||||
await peer.setLocalDescription(offer);
|
||||
socket.send(JSON.stringify({ action: 'sdp-offer', sdp: peer.localDescription.sdp }));
|
||||
log('Offre SDP envoyée');
|
||||
} catch (error) {
|
||||
log(`Offre impossible : ${error.message}`);
|
||||
disconnect();
|
||||
}
|
||||
};
|
||||
socket.onmessage = async ({ data }) => {
|
||||
try {
|
||||
const message = JSON.parse(data);
|
||||
if (message.action === 'answer') {
|
||||
await peer.setRemoteDescription({ type: 'answer', sdp: message.sdp });
|
||||
log('Réponse SDP appliquée');
|
||||
for (const candidate of pendingCandidates) await peer.addIceCandidate({ candidate, sdpMLineIndex: 0 });
|
||||
pendingCandidates = [];
|
||||
} else if (message.action === 'ice-candidate') {
|
||||
if (peer.remoteDescription) await peer.addIceCandidate({ candidate: message.candidate, sdpMLineIndex: 0 });
|
||||
else pendingCandidates.push(message.candidate);
|
||||
log('Candidat ICE distant reçu');
|
||||
} else if (message.action === 'error') {
|
||||
log(`Erreur serveur : ${message.message}`);
|
||||
} else {
|
||||
log(`Message inconnu : ${message.action}`);
|
||||
}
|
||||
} catch (error) {
|
||||
log(`Message impossible à traiter : ${error.message}`);
|
||||
}
|
||||
};
|
||||
socket.onerror = () => log('Erreur WebSocket (vérifier le serveur, le canal et le JWT).');
|
||||
socket.onclose = ({ code }) => {
|
||||
log(`WebSocket fermé (${code})`);
|
||||
disconnect();
|
||||
};
|
||||
};
|
||||
disconnectButton.onclick = disconnect;
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -30,7 +30,8 @@ pub async fn ws_entrypoint_handler(
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
|
||||
|
||||
let peer_connection = state.rtc.new_peer_connection();
|
||||
let mut rtc_client = RTCClient::new(user, channel, peer_connection, tx);
|
||||
let rtc_client = RTCClient::new(user, channel, peer_connection, tx, state.rtc.rooms.clone());
|
||||
let ice_task = rtc_client.forward_ice_candidates();
|
||||
|
||||
// Task pour envoyer les message au frontend depuis RTCClient
|
||||
let mut send_task = tokio::spawn(async move {
|
||||
@@ -45,12 +46,23 @@ pub async fn ws_entrypoint_handler(
|
||||
let client_clone = rtc_client.clone();
|
||||
let mut recv_task = tokio::spawn(async move {
|
||||
while let Some(Ok(message)) = receiver.next().await {
|
||||
client_clone.ws_on_message(message).await;
|
||||
if !client_clone.ws_on_message(message).await {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
tokio::select! {
|
||||
_ = (&mut send_task) => recv_task.abort(),
|
||||
_ = (&mut recv_task) => send_task.abort(),
|
||||
};
|
||||
_ = &mut send_task => {
|
||||
recv_task.abort();
|
||||
let _ = recv_task.await;
|
||||
}
|
||||
_ = &mut recv_task => {
|
||||
send_task.abort();
|
||||
let _ = send_task.await;
|
||||
}
|
||||
}
|
||||
ice_task.abort();
|
||||
let _ = ice_task.await;
|
||||
rtc_client.close();
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
use crate::domain::events::category::{
|
||||
CategoryCreatedEvent, CategoryDeletedEvent, CategoryUpdatedEvent,
|
||||
};
|
||||
use crate::models::category;
|
||||
use crate::models::server_item_order::OrderedResourceType;
|
||||
use crate::services::ServicesContext;
|
||||
@@ -49,7 +52,9 @@ impl CategoryService {
|
||||
})
|
||||
.await?;
|
||||
|
||||
event_bus.emit("category_created", cat.clone());
|
||||
event_bus.emit(CategoryCreatedEvent {
|
||||
category: cat.clone(),
|
||||
});
|
||||
|
||||
Ok(cat)
|
||||
}
|
||||
@@ -76,7 +81,9 @@ impl CategoryService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("category_updated", cat.clone());
|
||||
event_bus.emit(CategoryUpdatedEvent {
|
||||
category: cat.clone(),
|
||||
});
|
||||
|
||||
Ok(cat)
|
||||
}
|
||||
@@ -107,7 +114,7 @@ impl CategoryService {
|
||||
txn.commit().await?;
|
||||
|
||||
if deleted {
|
||||
event_bus.emit("category_deleted", existing);
|
||||
event_bus.emit(CategoryDeletedEvent { category: existing });
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
|
||||
+59
-30
@@ -2,11 +2,16 @@ use crate::domain::dto::channel::{CreateChannelRequest, UpdateChannelRequest};
|
||||
use crate::domain::events::channel::{
|
||||
ChannelCreatedEvent, ChannelDeletedEvent, ChannelUpdatedEvent,
|
||||
};
|
||||
use crate::domain::events::channel_permission::{
|
||||
ChannelRolePermissionUpdatedEvent, ChannelUserPermissionCreatedEvent,
|
||||
ChannelUserPermissionDeletedEvent,
|
||||
};
|
||||
use crate::models::server_item_order::OrderedResourceType;
|
||||
use crate::models::{channel, role};
|
||||
use crate::permissions::PermissionSet;
|
||||
use crate::services::ServicesContext;
|
||||
use crate::services::permission::PermissionService;
|
||||
use anyhow::Context;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
|
||||
use std::sync::Arc;
|
||||
use uuid::Uuid;
|
||||
@@ -91,12 +96,9 @@ impl ChannelService {
|
||||
.await?;
|
||||
|
||||
// Post-commit event emission
|
||||
event_bus.emit(
|
||||
"channel_created",
|
||||
ChannelCreatedEvent {
|
||||
channel: channel.clone(),
|
||||
},
|
||||
);
|
||||
event_bus.emit(ChannelCreatedEvent {
|
||||
channel: channel.clone(),
|
||||
});
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
@@ -141,13 +143,10 @@ impl ChannelService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit(
|
||||
"channel_updated",
|
||||
ChannelUpdatedEvent {
|
||||
previous,
|
||||
channel: channel.clone(),
|
||||
},
|
||||
);
|
||||
event_bus.emit(ChannelUpdatedEvent {
|
||||
previous,
|
||||
channel: channel.clone(),
|
||||
});
|
||||
|
||||
Ok(channel)
|
||||
}
|
||||
@@ -156,12 +155,7 @@ impl ChannelService {
|
||||
let db = &self.service_context.repositories.server.context.db;
|
||||
let event_bus = &self.service_context.event_bus;
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let existing = channel::Entity::find_by_id(id)
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
|
||||
let txn = db.begin().await.context("begin channel deletion transaction")?;
|
||||
|
||||
self.service_context
|
||||
.services
|
||||
@@ -169,16 +163,26 @@ impl ChannelService {
|
||||
.expect("services initialized")
|
||||
.server_order
|
||||
.remove(&txn, id, OrderedResourceType::Channel)
|
||||
.await?;
|
||||
.await
|
||||
.context("remove channel display order")?;
|
||||
|
||||
let res = channel::Entity::delete_by_id(id).exec(&txn).await?;
|
||||
let existing = channel::Entity::find_by_id(id)
|
||||
.one(&txn)
|
||||
.await
|
||||
.context("load channel for deletion")?
|
||||
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
|
||||
|
||||
let res = channel::Entity::delete_by_id(id)
|
||||
.exec(&txn)
|
||||
.await
|
||||
.context("delete channel record")?;
|
||||
|
||||
let deleted = res.rows_affected > 0;
|
||||
|
||||
txn.commit().await?;
|
||||
txn.commit().await.context("commit channel deletion")?;
|
||||
|
||||
if deleted {
|
||||
event_bus.emit("channel_deleted", ChannelDeletedEvent { channel: existing });
|
||||
event_bus.emit(ChannelDeletedEvent { channel: existing });
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
@@ -214,12 +218,15 @@ impl ChannelService {
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
|
||||
let permission = crate::models::channel_user_permission::Entity::find()
|
||||
.filter(crate::models::channel_user_permission::Column::ChannelId.eq(channel_id))
|
||||
.filter(crate::models::channel_user_permission::Column::UserId.eq(user_id))
|
||||
.one(&txn)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Permission not found"))?;
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit(
|
||||
"channel_user_permission_created",
|
||||
(channel_id, user_id, permissions),
|
||||
);
|
||||
event_bus.emit(ChannelUserPermissionCreatedEvent { permission });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -234,6 +241,11 @@ impl ChannelService {
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let existing = crate::models::channel_user_permission::Entity::find()
|
||||
.filter(crate::models::channel_user_permission::Column::ChannelId.eq(channel_id))
|
||||
.filter(crate::models::channel_user_permission::Column::UserId.eq(user_id))
|
||||
.one(&txn)
|
||||
.await?;
|
||||
crate::models::channel_user_permission::Entity::delete_many()
|
||||
.filter(crate::models::channel_user_permission::Column::ChannelId.eq(channel_id))
|
||||
.filter(crate::models::channel_user_permission::Column::UserId.eq(user_id))
|
||||
@@ -242,7 +254,9 @@ impl ChannelService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("channel_user_permission_deleted", (channel_id, user_id));
|
||||
if let Some(permission) = existing {
|
||||
event_bus.emit(ChannelUserPermissionDeletedEvent { permission });
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -285,9 +299,19 @@ impl ChannelService {
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
|
||||
let permission = crate::models::channel_role_permission::Entity::find()
|
||||
.filter(crate::models::channel_role_permission::Column::ChannelId.eq(channel_id))
|
||||
.filter(crate::models::channel_role_permission::Column::RoleId.eq(role_id))
|
||||
.one(&txn)
|
||||
.await?;
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("channel_role_permission_updated", (role_id, server_id));
|
||||
event_bus.emit(ChannelRolePermissionUpdatedEvent {
|
||||
channel_id,
|
||||
server_id,
|
||||
role_id,
|
||||
permission,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -318,7 +342,12 @@ impl ChannelService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("channel_role_permission_updated", (role_id, server_id));
|
||||
event_bus.emit(ChannelRolePermissionUpdatedEvent {
|
||||
channel_id,
|
||||
server_id,
|
||||
role_id,
|
||||
permission: None,
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+20
-56
@@ -3,7 +3,6 @@ use crate::domain::events::message::{
|
||||
};
|
||||
use crate::models::{attachment, channel, message};
|
||||
use crate::services::ServicesContext;
|
||||
use event_bus::Scope;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
|
||||
};
|
||||
@@ -41,9 +40,6 @@ impl MessageService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
let mut scopes: Vec<Scope> = Vec::new();
|
||||
scopes.push(Scope::uuid("channel", msg.channel_id));
|
||||
|
||||
let server_id: Option<Uuid> = channel::Entity::find_by_id(msg.channel_id)
|
||||
.select_only()
|
||||
.column(channel::Column::ServerId)
|
||||
@@ -52,19 +48,11 @@ impl MessageService {
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
if let Some(server_id) = server_id {
|
||||
scopes.push(Scope::uuid("server", server_id));
|
||||
}
|
||||
|
||||
event_bus.emit_scoped(
|
||||
"message_created",
|
||||
scopes,
|
||||
MessageCreatedEvent {
|
||||
server_id,
|
||||
channel_id: msg.channel_id,
|
||||
message: msg.clone(),
|
||||
},
|
||||
);
|
||||
event_bus.emit(MessageCreatedEvent {
|
||||
server_id,
|
||||
channel_id: msg.channel_id,
|
||||
message: msg.clone(),
|
||||
});
|
||||
|
||||
Ok(msg)
|
||||
}
|
||||
@@ -119,19 +107,11 @@ impl MessageService {
|
||||
.one(db)
|
||||
.await?
|
||||
.flatten();
|
||||
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)];
|
||||
if let Some(server_id) = server_id {
|
||||
scopes.push(Scope::uuid("server", server_id));
|
||||
}
|
||||
event_bus.emit_scoped(
|
||||
"message_created",
|
||||
scopes,
|
||||
MessageCreatedEvent {
|
||||
server_id,
|
||||
channel_id: msg.channel_id,
|
||||
message: msg.clone(),
|
||||
},
|
||||
);
|
||||
event_bus.emit(MessageCreatedEvent {
|
||||
server_id,
|
||||
channel_id: msg.channel_id,
|
||||
message: msg.clone(),
|
||||
});
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
@@ -165,19 +145,11 @@ impl MessageService {
|
||||
.await?
|
||||
.flatten();
|
||||
|
||||
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)];
|
||||
if let Some(server_id) = server_id {
|
||||
scopes.push(Scope::uuid("server", server_id));
|
||||
}
|
||||
event_bus.emit_scoped(
|
||||
"message_updated",
|
||||
scopes,
|
||||
MessageUpdatedEvent {
|
||||
server_id,
|
||||
channel_id: msg.channel_id,
|
||||
message: msg.clone(),
|
||||
},
|
||||
);
|
||||
event_bus.emit(MessageUpdatedEvent {
|
||||
server_id,
|
||||
channel_id: msg.channel_id,
|
||||
message: msg.clone(),
|
||||
});
|
||||
|
||||
Ok(msg)
|
||||
}
|
||||
@@ -208,19 +180,11 @@ impl MessageService {
|
||||
txn.commit().await?;
|
||||
|
||||
if deleted {
|
||||
let mut scopes = vec![Scope::uuid("channel", existing.channel_id)];
|
||||
if let Some(server_id) = server_id {
|
||||
scopes.push(Scope::uuid("server", server_id));
|
||||
}
|
||||
event_bus.emit_scoped(
|
||||
"message_deleted",
|
||||
scopes,
|
||||
MessageDeletedEvent {
|
||||
server_id,
|
||||
channel_id: existing.channel_id,
|
||||
message: existing,
|
||||
},
|
||||
);
|
||||
event_bus.emit(MessageDeletedEvent {
|
||||
server_id,
|
||||
channel_id: existing.channel_id,
|
||||
message: existing,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
|
||||
@@ -3,7 +3,6 @@ use crate::domain::events::message::{MessageReactionAddedEvent, MessageReactionR
|
||||
use crate::http::error::HTTPError;
|
||||
use crate::models::{emoji, message_reaction};
|
||||
use crate::services::ServicesContext;
|
||||
use event_bus::Scope;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -117,19 +116,11 @@ impl MessageReactionService {
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut scopes = vec![Scope::uuid("channel", message.channel_id)];
|
||||
if let Some(server_id) = server_id {
|
||||
scopes.push(Scope::uuid("server", server_id));
|
||||
}
|
||||
self.context.event_bus.emit_scoped(
|
||||
"message_reaction_added",
|
||||
scopes,
|
||||
MessageReactionAddedEvent {
|
||||
server_id,
|
||||
channel_id: message.channel_id,
|
||||
reaction: reaction.clone(),
|
||||
},
|
||||
);
|
||||
self.context.event_bus.emit(MessageReactionAddedEvent {
|
||||
server_id,
|
||||
channel_id: message.channel_id,
|
||||
reaction: reaction.clone(),
|
||||
});
|
||||
|
||||
Ok((reaction, true))
|
||||
}
|
||||
@@ -151,19 +142,11 @@ impl MessageReactionService {
|
||||
.await?
|
||||
.ok_or(HTTPError::NotFound)?;
|
||||
|
||||
let mut scopes = vec![Scope::uuid("channel", message.channel_id)];
|
||||
if let Some(server_id) = server_id {
|
||||
scopes.push(Scope::uuid("server", server_id));
|
||||
}
|
||||
self.context.event_bus.emit_scoped(
|
||||
"message_reaction_removed",
|
||||
scopes,
|
||||
MessageReactionRemovedEvent {
|
||||
server_id,
|
||||
channel_id: message.channel_id,
|
||||
reaction: reaction.clone(),
|
||||
},
|
||||
);
|
||||
self.context.event_bus.emit(MessageReactionRemovedEvent {
|
||||
server_id,
|
||||
channel_id: message.channel_id,
|
||||
reaction: reaction.clone(),
|
||||
});
|
||||
|
||||
Ok(reaction)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
use crate::domain::events::channel::{ChannelCreatedEvent, ChannelDeletedEvent};
|
||||
use crate::domain::events::channel_permission::{
|
||||
ChannelRolePermissionUpdatedEvent, ChannelUserPermissionCreatedEvent,
|
||||
ChannelUserPermissionDeletedEvent, ChannelUserPermissionUpdatedEvent,
|
||||
};
|
||||
use crate::domain::events::role::{RoleUserCreatedEvent, RoleUserDeletedEvent};
|
||||
use crate::domain::events::server::{
|
||||
ServerCreatedEvent, ServerUserCreatedEvent, ServerUserDeletedEvent,
|
||||
};
|
||||
use crate::domain::events::server_permission::{
|
||||
ServerRolePermissionUpdatedEvent, ServerUserPermissionUpdatedEvent,
|
||||
};
|
||||
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
|
||||
use crate::models::server;
|
||||
use crate::repositories::Repositories;
|
||||
use crate::services::ServicesContext;
|
||||
use event_bus::EventBus;
|
||||
@@ -60,13 +70,10 @@ pub struct PermissionSyncService {
|
||||
|
||||
impl PermissionSyncService {
|
||||
fn invalidate_tree(event_bus: &Arc<EventBus>, server_id: Uuid, user_ids: Option<Vec<Uuid>>) {
|
||||
event_bus.emit(
|
||||
"server_tree_invalidated",
|
||||
ServerTreeInvalidatedEvent {
|
||||
server_id,
|
||||
user_ids,
|
||||
},
|
||||
);
|
||||
event_bus.emit(ServerTreeInvalidatedEvent {
|
||||
server_id,
|
||||
user_ids,
|
||||
});
|
||||
}
|
||||
|
||||
pub fn new(service_context: Arc<ServicesContext>) -> Self {
|
||||
@@ -83,18 +90,18 @@ impl PermissionSyncService {
|
||||
// Événements Serveur & Membres Serveur
|
||||
// ---------------------------------------------------------------------
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"server_created",
|
||||
event_bus.on_async_with::<ServerCreatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, server: server::Model| async move {
|
||||
Self::sync_server(repositories, server.id).await;
|
||||
move |repositories, event| async move {
|
||||
Self::sync_server(repositories, event.server.id).await;
|
||||
},
|
||||
);
|
||||
|
||||
event_bus.on_async_with(
|
||||
"server_user_created",
|
||||
event_bus.on_async_with::<ServerUserCreatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (server_id, user_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let server_id = event.server_user.server_id;
|
||||
let user_id = event.server_user.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_user(repositories, user_id, server_id).await;
|
||||
@@ -104,10 +111,11 @@ impl PermissionSyncService {
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"server_user_deleted",
|
||||
event_bus.on_async_with::<ServerUserDeletedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (server_id, user_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let server_id = event.server_user.server_id;
|
||||
let user_id = event.server_user.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_user(repositories, user_id, server_id).await;
|
||||
@@ -121,10 +129,11 @@ impl PermissionSyncService {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"role_user_created",
|
||||
event_bus.on_async_with::<RoleUserCreatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (_role_id, user_id, server_id): (Uuid, Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let server_id = event.server_id;
|
||||
let user_id = event.role_user.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_user(repositories, user_id, server_id).await;
|
||||
@@ -134,10 +143,11 @@ impl PermissionSyncService {
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"role_user_deleted",
|
||||
event_bus.on_async_with::<RoleUserDeletedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (_role_id, user_id, server_id): (Uuid, Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let server_id = event.server_id;
|
||||
let user_id = event.role_user.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_user(repositories, user_id, server_id).await;
|
||||
@@ -151,10 +161,11 @@ impl PermissionSyncService {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"server_role_permission_updated",
|
||||
event_bus.on_async_with::<ServerRolePermissionUpdatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (role_id, server_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let role_id = event.role_id;
|
||||
let server_id = event.server_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_role_members(repositories, role_id, server_id).await;
|
||||
@@ -164,10 +175,11 @@ impl PermissionSyncService {
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"server_user_permission_updated",
|
||||
event_bus.on_async_with::<ServerUserPermissionUpdatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (server_id, user_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let server_id = event.server_id;
|
||||
let user_id = event.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_user(repositories, user_id, server_id).await;
|
||||
@@ -181,10 +193,9 @@ impl PermissionSyncService {
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"channel_created",
|
||||
event_bus.on_async_with::<ChannelCreatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, event: ChannelCreatedEvent| {
|
||||
move |repositories, event| {
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
if let Some(server_id) = event.channel.server_id {
|
||||
@@ -196,10 +207,9 @@ impl PermissionSyncService {
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"channel_deleted",
|
||||
event_bus.on_async_with::<ChannelDeletedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, event: ChannelDeletedEvent| {
|
||||
move |repositories, event| {
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
if let Some(server_id) = event.channel.server_id {
|
||||
@@ -211,10 +221,11 @@ impl PermissionSyncService {
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"channel_role_permission_updated",
|
||||
event_bus.on_async_with::<ChannelRolePermissionUpdatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (role_id, server_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let role_id = event.role_id;
|
||||
let server_id = event.server_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_role_members(repositories, role_id, server_id).await;
|
||||
@@ -224,23 +235,34 @@ impl PermissionSyncService {
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"channel_user_permission_updated",
|
||||
event_bus.on_async_with::<ChannelUserPermissionUpdatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (server_id, user_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let user_id = event.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
Self::sync_user(repositories, user_id, server_id).await;
|
||||
Self::invalidate_tree(¬ify, server_id, Some(vec![user_id]));
|
||||
if let Some(channel) = repositories
|
||||
.channel
|
||||
.get_by_id(event.channel_id)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
{
|
||||
if let Some(server_id) = channel.server_id {
|
||||
Self::sync_user(repositories, user_id, server_id).await;
|
||||
Self::invalidate_tree(¬ify, server_id, Some(vec![user_id]));
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"channel_user_permission_created",
|
||||
event_bus.on_async_with::<ChannelUserPermissionCreatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (channel_id, user_id, _permissions): (Uuid, Uuid, u64)| {
|
||||
move |repositories, event| {
|
||||
let channel_id = event.permission.channel_id;
|
||||
let user_id = event.permission.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
if let Some(channel) = repositories
|
||||
@@ -260,10 +282,11 @@ impl PermissionSyncService {
|
||||
);
|
||||
|
||||
let notify = event_bus.clone();
|
||||
event_bus.on_async_with(
|
||||
"channel_user_permission_deleted",
|
||||
event_bus.on_async_with::<ChannelUserPermissionDeletedEvent, _>(
|
||||
repositories,
|
||||
move |repositories, (channel_id, user_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let channel_id = event.permission.channel_id;
|
||||
let user_id = event.permission.user_id;
|
||||
let notify = notify.clone();
|
||||
async move {
|
||||
if let Some(channel) = repositories
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
use crate::domain::events::channel_permission::{
|
||||
ChannelUserPermissionCreatedEvent, ChannelUserPermissionDeletedEvent,
|
||||
ChannelUserPermissionUpdatedEvent,
|
||||
};
|
||||
use crate::domain::events::server_permission::ServerUserPermissionUpdatedEvent;
|
||||
use crate::models::{channel, channel_user, computed_permission::PermissionScopeType};
|
||||
use crate::permissions::ChannelPermission;
|
||||
use crate::repositories::Repositories;
|
||||
@@ -204,10 +209,10 @@ impl RealtimeRegistry {
|
||||
event_bus: Arc<EventBus>,
|
||||
) {
|
||||
let registry = Arc::clone(self);
|
||||
event_bus.on_async_with(
|
||||
"channel_user_permission_updated",
|
||||
event_bus.on_async_with::<ChannelUserPermissionUpdatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (_channel_id, user_id, _permissions): (Uuid, Uuid, u64)| {
|
||||
move |repositories, event| {
|
||||
let user_id = event.user_id;
|
||||
let registry = Arc::clone(®istry);
|
||||
async move {
|
||||
if let Err(error) = registry.refresh_user(&repositories, user_id).await {
|
||||
@@ -218,10 +223,10 @@ impl RealtimeRegistry {
|
||||
);
|
||||
|
||||
let registry = Arc::clone(self);
|
||||
event_bus.on_async_with(
|
||||
"channel_user_permission_created",
|
||||
event_bus.on_async_with::<ChannelUserPermissionCreatedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (_channel_id, user_id, _permissions): (Uuid, Uuid, u64)| {
|
||||
move |repositories, event| {
|
||||
let user_id = event.permission.user_id;
|
||||
let registry = Arc::clone(®istry);
|
||||
async move {
|
||||
if let Err(error) = registry.refresh_user(&repositories, user_id).await {
|
||||
@@ -232,10 +237,10 @@ impl RealtimeRegistry {
|
||||
);
|
||||
|
||||
let registry = Arc::clone(self);
|
||||
event_bus.on_async_with(
|
||||
"channel_user_permission_deleted",
|
||||
event_bus.on_async_with::<ChannelUserPermissionDeletedEvent, _>(
|
||||
repositories.clone(),
|
||||
move |repositories, (_channel_id, user_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let user_id = event.permission.user_id;
|
||||
let registry = Arc::clone(®istry);
|
||||
async move {
|
||||
if let Err(error) = registry.refresh_user(&repositories, user_id).await {
|
||||
@@ -247,10 +252,10 @@ impl RealtimeRegistry {
|
||||
|
||||
let registry = Arc::clone(self);
|
||||
let repositories = repositories.clone();
|
||||
event_bus.on_async_with(
|
||||
"server_user_permission_updated",
|
||||
event_bus.on_async_with::<ServerUserPermissionUpdatedEvent, _>(
|
||||
repositories,
|
||||
move |repositories, (_server_id, user_id): (Uuid, Uuid)| {
|
||||
move |repositories, event| {
|
||||
let user_id = event.user_id;
|
||||
let registry = Arc::clone(®istry);
|
||||
async move {
|
||||
if let Err(error) = registry.refresh_user(&repositories, user_id).await {
|
||||
|
||||
+26
-12
@@ -1,4 +1,8 @@
|
||||
use crate::models::role;
|
||||
use crate::domain::events::role::{
|
||||
RoleCreatedEvent, RoleDeletedEvent, RoleUpdatedEvent, RoleUserCreatedEvent,
|
||||
RoleUserDeletedEvent,
|
||||
};
|
||||
use crate::models::{role, role_user};
|
||||
use crate::services::ServicesContext;
|
||||
use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait};
|
||||
use std::sync::Arc;
|
||||
@@ -27,7 +31,7 @@ impl RoleService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("role_created", role.clone());
|
||||
event_bus.emit(RoleCreatedEvent { role: role.clone() });
|
||||
|
||||
Ok(role)
|
||||
}
|
||||
@@ -45,7 +49,7 @@ impl RoleService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("role_updated", role.clone());
|
||||
event_bus.emit(RoleUpdatedEvent { role: role.clone() });
|
||||
|
||||
Ok(role)
|
||||
}
|
||||
@@ -56,14 +60,15 @@ impl RoleService {
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let existing = role::Entity::find_by_id(id).one(&txn).await?;
|
||||
let res = role::Entity::delete_by_id(id).exec(&txn).await?;
|
||||
|
||||
let deleted = res.rows_affected > 0;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
if deleted {
|
||||
event_bus.emit("role_deleted", id);
|
||||
if let Some(role) = existing.filter(|_| deleted) {
|
||||
event_bus.emit(RoleDeletedEvent { role });
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
@@ -82,9 +87,14 @@ impl RoleService {
|
||||
.add_member(role_id, user_id)
|
||||
.await?;
|
||||
if added {
|
||||
self.service_context
|
||||
.event_bus
|
||||
.emit("role_user_created", (role_id, user_id, server_id));
|
||||
let role_user = role_user::Entity::find_by_id((role_id, user_id))
|
||||
.one(&self.service_context.repositories.server.context.db)
|
||||
.await?
|
||||
.ok_or_else(|| anyhow::anyhow!("Role membership not found"))?;
|
||||
self.service_context.event_bus.emit(RoleUserCreatedEvent {
|
||||
role_user,
|
||||
server_id,
|
||||
});
|
||||
}
|
||||
Ok(added)
|
||||
}
|
||||
@@ -95,16 +105,20 @@ impl RoleService {
|
||||
user_id: Uuid,
|
||||
server_id: Uuid,
|
||||
) -> Result<bool, anyhow::Error> {
|
||||
let existing = role_user::Entity::find_by_id((role_id, user_id))
|
||||
.one(&self.service_context.repositories.server.context.db)
|
||||
.await?;
|
||||
let removed = self
|
||||
.service_context
|
||||
.repositories
|
||||
.role
|
||||
.remove_member(role_id, user_id)
|
||||
.await?;
|
||||
if removed {
|
||||
self.service_context
|
||||
.event_bus
|
||||
.emit("role_user_deleted", (role_id, user_id, server_id));
|
||||
if let Some(role_user) = existing.filter(|_| removed) {
|
||||
self.service_context.event_bus.emit(RoleUserDeletedEvent {
|
||||
role_user,
|
||||
server_id,
|
||||
});
|
||||
}
|
||||
Ok(removed)
|
||||
}
|
||||
|
||||
+29
-9
@@ -1,3 +1,7 @@
|
||||
use crate::domain::events::server::{
|
||||
ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent, ServerUserCreatedEvent,
|
||||
ServerUserDeletedEvent,
|
||||
};
|
||||
use crate::models::{role, role_user, server, server_user};
|
||||
use crate::services::ServicesContext;
|
||||
use sea_orm::{
|
||||
@@ -63,7 +67,9 @@ impl ServerService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("server_created", srv.clone());
|
||||
event_bus.emit(ServerCreatedEvent {
|
||||
server: srv.clone(),
|
||||
});
|
||||
|
||||
Ok(srv)
|
||||
}
|
||||
@@ -97,7 +103,7 @@ impl ServerService {
|
||||
return Ok(srv);
|
||||
}
|
||||
|
||||
server_user::ActiveModel {
|
||||
let membership = server_user::ActiveModel {
|
||||
server_id: Set(server_id),
|
||||
user_id: Set(user_id),
|
||||
..Default::default()
|
||||
@@ -119,7 +125,9 @@ impl ServerService {
|
||||
.await?;
|
||||
|
||||
txn.commit().await?;
|
||||
event_bus.emit("server_user_created", (server_id, user_id));
|
||||
event_bus.emit(ServerUserCreatedEvent {
|
||||
server_user: membership,
|
||||
});
|
||||
self.service_context
|
||||
.repositories
|
||||
.read_state
|
||||
@@ -152,7 +160,9 @@ impl ServerService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("server_updated", srv.clone());
|
||||
event_bus.emit(ServerUpdatedEvent {
|
||||
server: srv.clone(),
|
||||
});
|
||||
|
||||
Ok(srv)
|
||||
}
|
||||
@@ -182,7 +192,10 @@ impl ServerService {
|
||||
txn.commit().await?;
|
||||
|
||||
if deleted {
|
||||
event_bus.emit("server_deleted", (existing, audience));
|
||||
event_bus.emit(ServerDeletedEvent {
|
||||
server: existing,
|
||||
audience,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
@@ -194,7 +207,7 @@ impl ServerService {
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
crate::models::server_user::ActiveModel {
|
||||
let membership = crate::models::server_user::ActiveModel {
|
||||
server_id: Set(server_id),
|
||||
user_id: Set(user_id),
|
||||
..Default::default()
|
||||
@@ -214,7 +227,9 @@ impl ServerService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("server_user_created", (server_id, user_id));
|
||||
event_bus.emit(ServerUserCreatedEvent {
|
||||
server_user: membership,
|
||||
});
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
@@ -225,6 +240,11 @@ impl ServerService {
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let membership = server_user::Entity::find()
|
||||
.filter(server_user::Column::ServerId.eq(server_id))
|
||||
.filter(server_user::Column::UserId.eq(user_id))
|
||||
.one(&txn)
|
||||
.await?;
|
||||
let res = crate::models::server_user::Entity::delete_many()
|
||||
.filter(crate::models::server_user::Column::ServerId.eq(server_id))
|
||||
.filter(crate::models::server_user::Column::UserId.eq(user_id))
|
||||
@@ -235,8 +255,8 @@ impl ServerService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
if deleted {
|
||||
event_bus.emit("server_user_deleted", (server_id, user_id));
|
||||
if let Some(server_user) = membership.filter(|_| deleted) {
|
||||
event_bus.emit(ServerUserDeletedEvent { server_user });
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
use crate::auth::password;
|
||||
use crate::domain::events::user::{
|
||||
UserChangedEvent, UserCreatedEvent, UserDeletedEvent, UserUpdatedEvent,
|
||||
};
|
||||
use crate::models::{role, user};
|
||||
use crate::services::ServicesContext;
|
||||
use sea_orm::{
|
||||
@@ -30,7 +33,7 @@ impl UserService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("user_created", usr.clone());
|
||||
event_bus.emit(UserCreatedEvent { user: usr.clone() });
|
||||
|
||||
Ok(usr)
|
||||
}
|
||||
@@ -48,7 +51,7 @@ impl UserService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("user_updated", usr.clone());
|
||||
event_bus.emit(UserUpdatedEvent { user: usr.clone() });
|
||||
|
||||
Ok(usr)
|
||||
}
|
||||
@@ -79,7 +82,7 @@ impl UserService {
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
event_bus.emit("user_changed", usr);
|
||||
event_bus.emit(UserChangedEvent { user: usr });
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -90,14 +93,15 @@ impl UserService {
|
||||
|
||||
let txn = db.begin().await?;
|
||||
|
||||
let existing = user::Entity::find_by_id(id).one(&txn).await?;
|
||||
let res = user::Entity::delete_by_id(id).exec(&txn).await?;
|
||||
|
||||
let deleted = res.rows_affected > 0;
|
||||
|
||||
txn.commit().await?;
|
||||
|
||||
if deleted {
|
||||
event_bus.emit("user_deleted", id);
|
||||
if let Some(user) = existing.filter(|_| deleted) {
|
||||
event_bus.emit(UserDeletedEvent { user });
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
|
||||
@@ -341,6 +341,9 @@ mod tests {
|
||||
hostv6: None,
|
||||
tcp_port: 8080,
|
||||
udp_port: 9000,
|
||||
external_ip: None,
|
||||
stun_servers: vec![],
|
||||
tls: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user