Compare commits

...
15 Commits
Author SHA1 Message Date
Nell 0cb8b068a8 add permission table 2026-09-27 18:15:55 +02:00
Nell 910eb2b169 add test and permission check 2026-09-24 21:10:11 +02:00
Nell 844eaadee0 fix webrtc and login 2026-09-24 13:55:59 +02:00
Nell 5bac3174df add event_bus_typed 2026-09-23 21:11:35 +02:00
Nell b830abe3f4 add event_bus_typed 2026-09-23 19:05:09 +02:00
Nell d1ce0655fb add event_bus_typed 2026-09-23 14:30:19 +02:00
Nell b234359c4a add webrtc 2026-09-23 13:58:42 +02:00
Nell ab97dcc8d9 add event_bus_typed 2026-09-23 10:36:14 +02:00
Nell 3780092fa6 add event_bus_typed 2026-09-23 01:21:53 +02:00
Nell ff71166e28 add event_bus_typed 2026-09-23 01:21:50 +02:00
Nell 57e4da2ba4 init 2026-09-21 20:30:21 +02:00
Nell 73233e9b57 init 2026-09-21 15:19:13 +02:00
Nell 7f1402dd33 init 2026-09-20 20:59:41 +02:00
Nell 8297111060 init 2026-09-19 21:34:06 +02:00
Nell 8d48b1617b init 2026-09-13 20:52:07 +02:00
101 changed files with 5962 additions and 1425 deletions
+1
View File
@@ -2,3 +2,4 @@
/.idea /.idea
*.db* *.db*
/media/* /media/*
/certs
+118 -47
View File
@@ -5,66 +5,109 @@ sessionId: session-260912-132834-fuse
# Requirements # Requirements
### Overview & Goals ### Overview & Goals
Replace the current placeholder raw-UDP voice relay (`src/udp/*`) with a real WebRTC media stack built on the `rustrtc` crate (already declared in `Cargo.toml`). The server becomes a centralized **SFU** (Selective Forwarding Unit): each connected client opens **one `PeerConnection`** with the server, the server decrypts/receives that client's audio (and later video) track and forwards it to every other member of the same voice `Channel`.
Replace the current placeholder raw-UDP voice relay (`src/udp/*`) with a real WebRTC media stack built on the `rustrtc`
crate (already declared in `Cargo.toml`). The server becomes a centralized **SFU** (Selective Forwarding Unit): each
connected client opens **one `PeerConnection`** with the server, the server decrypts/receives that client's audio (and
later video) track and forwards it to every other member of the same voice `Channel`.
### Scope ### Scope
**In Scope** **In Scope**
- Remove `src/udp/server.rs`, `src/udp/router.rs` raw-socket logic (metrics module is kept/adapted). - Remove `src/udp/server.rs`, `src/udp/router.rs` raw-socket logic (metrics module is kept/adapted).
- Add a `voice` module hosting: `PeerConnection` lifecycle management via `rustrtc`, SDP offer/answer exchange, ICE candidate exchange, and SFU-style track forwarding scoped by `channel_id`. - Add a `voice` module hosting: `PeerConnection` lifecycle management via `rustrtc`, SDP offer/answer exchange, ICE
- Reuse the existing WebSocket gateway (`GatewayManager` / `RealtimeRouter` / `on_message` in `src/routes/gateway/mod.rs`) as the signaling transport — add a `Voice` namespace to the existing JSON event envelope (`GatewayEvent { namespace, action, content }`) instead of introducing a new endpoint. candidate exchange, and SFU-style track forwarding scoped by `channel_id`.
- Reuse `channel::ChannelType::Voice`, `channel_user`, and the `JOIN_VOICE` / `SPEAK` permission bits already defined in `src/permissions.rs` to authorize who may join/publish in a voice channel. - Reuse the existing WebSocket gateway (`GatewayManager` / `RealtimeRouter` / `ws_on_message` in
- Update `config.toml` / `NetworkConfig` so the single `udp_port` continues to be the one UDP port used, bound through `rustrtc`'s `ice_udp_mux` (single-port ICE) instead of a raw `UdpSocket`. `src/routes/gateway/mod.rs`) as the signaling transport — add a `Voice` namespace to the existing JSON event envelope
- Update `AppMetrics`/`core::mod::App::run` wiring so the new voice service starts/stops alongside the HTTP server, replacing `UdpServer`. (`GatewayEvent { namespace, action, content }`) instead of introducing a new endpoint.
- Reuse `channel::ChannelType::Voice`, `channel_user`, and the `JOIN_VOICE` / `SPEAK` permission bits already defined in
`src/permissions.rs` to authorize who may join/publish in a voice channel.
- Update `config.toml` / `NetworkConfig` so the single `udp_port` continues to be the one UDP port used, bound through
`rustrtc`'s `ice_udp_mux` (single-port ICE) instead of a raw `UdpSocket`.
- Update `AppMetrics`/`core::mod::App::run` wiring so the new voice service starts/stops alongside the HTTP server,
replacing `UdpServer`.
**Out of Scope** **Out of Scope**
- TURN/STUN server configuration (kept minimal for LAN/local network scenarios for now). - TURN/STUN server configuration (kept minimal for LAN/local network scenarios for now).
- Video/screen-share specific handling (structure will allow it later, but only audio forwarding is implemented now). - Video/screen-share specific handling (structure will allow it later, but only audio forwarding is implemented now).
- Client-side (frontend) implementation details beyond the signaling contract needed by the server. - Client-side (frontend) implementation details beyond the signaling contract needed by the server.
### User Stories ### User Stories
- As a user with `JOIN_VOICE` permission on a voice channel, I want to connect and have my microphone audio heard by other members of that same channel.
- As a user already in a voice channel, I want to hear every other member's audio forwarded by the server without opening a direct connection to each peer. - As a user with `JOIN_VOICE` permission on a voice channel, I want to connect and have my microphone audio heard by
- As a server operator, I want the previous ad-hoc UDP relay removed and replaced by a maintained WebRTC stack (rustrtc) so voice traffic is encrypted (SRTP) and NAT-traversal (ICE) works out of the box. other members of that same channel.
- As a user already in a voice channel, I want to hear every other member's audio forwarded by the server without
opening a direct connection to each peer.
- As a server operator, I want the previous ad-hoc UDP relay removed and replaced by a maintained WebRTC stack (rustrtc)
so voice traffic is encrypted (SRTP) and NAT-traversal (ICE) works out of the box.
### Functional Requirements ### Functional Requirements
- A client sends an SDP offer through the gateway (`namespace: "Voice"`), scoped to a `channel_id`; the server validates channel membership/permissions, creates a `rustrtc::PeerConnection`, and returns an SDP answer through the same gateway connection.
- ICE candidates generated by either side are exchanged as additional `Voice` gateway events.
- Once connected, the server subscribes the client's inbound audio track to every other `PeerConnection` currently joined to the same voice channel (SFU fan-out), and adds new joiners' tracks to previously-connected peers as they arrive.
- When a user disconnects (gateway socket closes) or leaves the voice channel, their `PeerConnection` is closed and their track is removed from all other peers' forwarding sets.
- Existing `UdpMetrics`-style counters (packets/bytes in/out, errors) are preserved in spirit, adapted for voice metrics and reported by the periodic `reporter`.
- A client sends an SDP offer through the gateway (`namespace: "Voice"`), scoped to a `channel_id`; the server validates
channel membership/permissions, creates a `rustrtc::PeerConnection`, and returns an SDP answer through the same
gateway connection.
- ICE candidates generated by either side are exchanged as additional `Voice` gateway events.
- Once connected, the server subscribes the client's inbound audio track to every other `PeerConnection` currently
joined to the same voice channel (SFU fan-out), and adds new joiners' tracks to previously-connected peers as they
arrive.
- When a user disconnects (gateway socket closes) or leaves the voice channel, their `PeerConnection` is closed and
their track is removed from all other peers' forwarding sets.
- Existing `UdpMetrics`-style counters (packets/bytes in/out, errors) are preserved in spirit, adapted for voice metrics
and reported by the periodic `reporter`.
# Technical Design # Technical Design
### Current Implementation ### Current Implementation
- `src/udp/server.rs`: binds a raw `tokio::net::UdpSocket` on `NetworkConfig.udp_port`, loops on `recv_from`, and blindly forwards datagrams to registered peers under a hard-coded `"default"` channel without protocol isolation or encryption.
- `src/udp/server.rs`: binds a raw `tokio::net::UdpSocket` on `NetworkConfig.udp_port`, loops on `recv_from`, and
blindly forwards datagrams to registered peers under a hard-coded `"default"` channel without protocol isolation or
encryption.
- `src/udp/router.rs`: `RoutingTable` maps `ChannelId -> Vec<SocketAddr>`; dead placeholder API. - `src/udp/router.rs`: `RoutingTable` maps `ChannelId -> Vec<SocketAddr>`; dead placeholder API.
- `src/udp/metrics.rs`: atomic counters (`packets_received`, `bytes_sent`, etc.) + `spawn_reporter` logging every interval, plugged into `AppMetrics.udp` (`src/metrics/mod.rs`). - `src/udp/metrics.rs`: atomic counters (`packets_received`, `bytes_sent`, etc.) + `spawn_reporter` logging every
- `src/core/mod.rs`: `App::build`/`App::run` constructs `UdpServer::new(&config.network, udp_metrics)` and `tokio::spawn(udp_server.run())`, joined into shutdown-broadcast pattern. interval, plugged into `AppMetrics.udp` (`src/metrics/mod.rs`).
- `src/routes/gateway/mod.rs`: `GatewayManager` tracks client WebSocket connections; `GatewayClient::on_message` handles incoming messages and acts as the extension point for voice signaling. - `src/core/mod.rs`: `App::build`/`App::run` constructs `UdpServer::new(&config.network, udp_metrics)` and
- `src/services/realtime_registry.rs`: maintains `channel_id -> HashSet<user_id>` membership computed from `computed_permission` (READ_CHANNEL). `tokio::spawn(udp_server.run())`, joined into shutdown-broadcast pattern.
- `src/permissions.rs`: `JOIN_VOICE`, `SPEAK`, `STREAM`, `MOVE_OTHERS`, `DISCONNECT_OTHERS`, `MANAGE_VOICE_CHANNEL` bits already defined. - `src/routes/gateway/mod.rs`: `GatewayManager` tracks client WebSocket connections; `GatewayClient::on_message` handles
incoming messages and acts as the extension point for voice signaling.
- `src/services/realtime_registry.rs`: maintains `channel_id -> HashSet<user_id>` membership computed from
`computed_permission` (READ_CHANNEL).
- `src/permissions.rs`: `JOIN_VOICE`, `SPEAK`, `STREAM`, `MOVE_OTHERS`, `DISCONNECT_OTHERS`, `MANAGE_VOICE_CHANNEL` bits
already defined.
- `src/models/channel.rs`: `ChannelType::Voice` variant already exists. - `src/models/channel.rs`: `ChannelType::Voice` variant already exists.
### Key Decisions ### Key Decisions
1. **Topology: centralized SFU** — one `rustrtc::PeerConnection` per connected client per voice channel; the server forwards each publisher's RTP track to every other subscriber in that channel. No direct peer-to-peer mesh.
2. **Signaling transport: existing WebSocket gateway** — SDP offer/answer and ICE candidates are carried as `Voice` namespace events inside the current `GatewayEvent` envelope, handled in `GatewayClient::on_message` and dispatched to `VoiceService`. 1. **Topology: centralized SFU** — one `rustrtc::PeerConnection` per connected client per voice channel; the server
3. **Single UDP port reuse** — `rustrtc`'s `RtcConfiguration` is set up with `ice_udp_mux = true` and bound to `NetworkConfig.udp_port`, preserving existing firewall/network port configurations. forwards each publisher's RTP track to every other subscriber in that channel. No direct peer-to-peer mesh.
2. **Signaling transport: existing WebSocket gateway** — SDP offer/answer and ICE candidates are carried as `Voice`
namespace events inside the current `GatewayEvent` envelope, handled in `GatewayClient::on_message` and dispatched to
`VoiceService`.
3. **Single UDP port reuse** — `rustrtc`'s `RtcConfiguration` is set up with `ice_udp_mux = true` and bound to
`NetworkConfig.udp_port`, preserving existing firewall/network port configurations.
4. **No TURN/STUN for now** — `RtcConfiguration.ice_servers` left default/empty for local and direct network setups. 4. **No TURN/STUN for now** — `RtcConfiguration.ice_servers` left default/empty for local and direct network setups.
5. **Voice presence tracked separately from `RealtimeRegistry`** — an in-memory registry (`VoiceRoom` per `channel_id`) tracks active WebRTC connections independently from general WebSocket channel presence. 5. **Voice presence tracked separately from `RealtimeRegistry`** — an in-memory registry (`VoiceRoom` per `channel_id`)
tracks active WebRTC connections independently from general WebSocket channel presence.
### Proposed Changes ### Proposed Changes
- Replace `src/udp` module with `src/voice`: - Replace `src/udp` module with `src/voice`:
- `voice/mod.rs`: public API, exports `VoiceService`. - `voice/mod.rs`: public API, exports `VoiceService`.
- `voice/service.rs`: `VoiceService` owns `rustrtc::RtcConfiguration`, a map `channel_id -> VoiceRoom`, and handler methods for offer, ICE candidates, and channel leaves. - `voice/service.rs`: `VoiceService` owns `rustrtc::RtcConfiguration`, a map `channel_id -> VoiceRoom`, and handler
- `voice/room.rs`: `VoiceRoom` holds `HashMap<user_id, Arc<PeerConnection>>` for a channel and orchestrates track fan-out across peers. methods for offer, ICE candidates, and channel leaves.
- `voice/metrics.rs`: tracks voice counters (packets/bytes/errors), exposed through `AppMetrics.voice` and the existing reporter cadence. - `voice/room.rs`: `VoiceRoom` holds `HashMap<user_id, Arc<PeerConnection>>` for a channel and orchestrates track
fan-out across peers.
- `voice/metrics.rs`: tracks voice counters (packets/bytes/errors), exposed through `AppMetrics.voice` and the
existing reporter cadence.
- Extend `GatewayEvent` handling: add `"Voice"` namespace with actions `offer`, `answer`, `ice-candidate`, `leave`. - Extend `GatewayEvent` handling: add `"Voice"` namespace with actions `offer`, `answer`, `ice-candidate`, `leave`.
- Permission check on `offer`: verify `ChannelPermission::JOIN_VOICE` and `SPEAK` before initializing a `PeerConnection`. - Permission check on `offer`: verify `ChannelPermission::JOIN_VOICE` and `SPEAK` before initializing a
`PeerConnection`.
- Update `src/core/mod.rs`: replace `UdpServer` initialization with `VoiceService` held inside `AppState`. - Update `src/core/mod.rs`: replace `UdpServer` initialization with `VoiceService` held inside `AppState`.
- Update `src/config.rs`: keep `NetworkConfig.udp_port` feeding `rustrtc`'s `ice_udp_mux_port`. - Update `src/config.rs`: keep `NetworkConfig.udp_port` feeding `rustrtc`'s `ice_udp_mux_port`.
### Data Models / Contracts ### Data Models / Contracts
```rust ```rust
// Gateway JSON contract additions (namespace = "Voice") // Gateway JSON contract additions (namespace = "Voice")
{ "namespace": "Voice", "action": "offer", "content": { "channel_id": Uuid, "sdp": String } } { "namespace": "Voice", "action": "offer", "content": { "channel_id": Uuid, "sdp": String } }
@@ -87,6 +130,7 @@ impl VoiceService {
``` ```
### Architecture Diagram ### Architecture Diagram
```mermaid ```mermaid
graph TD graph TD
ClientA[Client A] -- WebSocket: Voice offer/answer/ICE --> Gateway[GatewayManager / on_message] ClientA[Client A] -- WebSocket: Voice offer/answer/ICE --> Gateway[GatewayManager / on_message]
@@ -102,39 +146,66 @@ graph TD
``` ```
### Risks ### Risks
- `rustrtc` API details: verify track forwarding primitives against `rustrtc 0.3.133` during implementation and encapsulate track re-subscription in `voice/room.rs`.
- Permission enforcement: ensure permission checks take place before creating peer connections to prevent unauthorized audio relay.
- Signaling message volume: ensure gateway handling remains non-blocking for high frequency ICE candidates.
- `rustrtc` API details: verify track forwarding primitives against `rustrtc 0.3.133` during implementation and
encapsulate track re-subscription in `voice/room.rs`.
- Permission enforcement: ensure permission checks take place before creating peer connections to prevent unauthorized
audio relay.
- Signaling message volume: ensure gateway handling remains non-blocking for high frequency ICE candidates.
# Delivery Steps # Delivery Steps
### Step 1: Scaffold the voice module and RtcConfiguration from NetworkConfig ### Step 1: Scaffold the voice module and RtcConfiguration from NetworkConfig
A new `src/voice` module exists with a `VoiceService` capable of building a `rustrtc::RtcConfiguration` from the app's network config, replacing the old `src/udp` module's role in `core/mod.rs`.
A new `src/voice` module exists with a `VoiceService` capable of building a `rustrtc::RtcConfiguration` from the app's
network config, replacing the old `src/udp` module's role in `core/mod.rs`.
- Create `src/voice/mod.rs`, `src/voice/service.rs`, `src/voice/room.rs`, and `src/voice/metrics.rs`. - Create `src/voice/mod.rs`, `src/voice/service.rs`, `src/voice/room.rs`, and `src/voice/metrics.rs`.
- Port `UdpMetrics`-style counters into `voice/metrics.rs` (packets/bytes in/out, errors), keeping the `Metrics`/`MetricsSnapshot` trait implementations used by `crate::metrics::reporter`. - Port `UdpMetrics`-style counters into `voice/metrics.rs` (packets/bytes in/out, errors), keeping the `Metrics`/
- Build `RtcConfiguration`/`RtcConfigurationBuilder` in `VoiceService::new(&NetworkConfig, metrics)` using `ice_udp_mux = true` and `ice_udp_mux_port = network.udp_port`, with no external ICE servers configured. `MetricsSnapshot` trait implementations used by `crate::metrics::reporter`.
- Build `RtcConfiguration`/`RtcConfigurationBuilder` in `VoiceService::new(&NetworkConfig, metrics)` using
`ice_udp_mux = true` and `ice_udp_mux_port = network.udp_port`, with no external ICE servers configured.
- Remove `src/udp/server.rs` and `src/udp/router.rs`; delete unused `RoutingTable`. - Remove `src/udp/server.rs` and `src/udp/router.rs`; delete unused `RoutingTable`.
- Update `src/core/mod.rs` (`App::build`/`App::run`) to construct `VoiceService` instead of `UdpServer`, store it on `AppState`, and drop unneeded `udp_shutdown_tx`/`udp_handle` tasks. - Update `src/core/mod.rs` (`App::build`/`App::run`) to construct `VoiceService` instead of `UdpServer`, store it on
`AppState`, and drop unneeded `udp_shutdown_tx`/`udp_handle` tasks.
- Update `src/metrics/mod.rs` (`AppMetrics`) to reference the new voice metrics type. - Update `src/metrics/mod.rs` (`AppMetrics`) to reference the new voice metrics type.
### Step 2: Implement per-channel PeerConnection lifecycle in VoiceRoom ### Step 2: Implement per-channel PeerConnection lifecycle in VoiceRoom
`VoiceService`/`VoiceRoom` can accept an SDP offer for a given channel, create a `rustrtc::PeerConnection`, and return an SDP answer, tracking connections per `channel_id`.
`VoiceService`/`VoiceRoom` can accept an SDP offer for a given channel, create a `rustrtc::PeerConnection`, and return
an SDP answer, tracking connections per `channel_id`.
- Implement `VoiceRoom` (`channel_id -> HashMap<user_id, Arc<PeerConnection>>`). - Implement `VoiceRoom` (`channel_id -> HashMap<user_id, Arc<PeerConnection>>`).
- Implement `VoiceService::handle_offer(user_id, channel_id, sdp)`: looks up or creates the room, creates a `PeerConnection` via `rustrtc`, invokes `set_remote_description`/`create_answer`/`set_local_description`, and returns the answer SDP. - Implement `VoiceService::handle_offer(user_id, channel_id, sdp)`: looks up or creates the room, creates a
- Implement `VoiceService::handle_ice_candidate` and `VoiceService::leave`, closing and removing the `PeerConnection` from its room. `PeerConnection` via `rustrtc`, invokes `set_remote_description`/`create_answer`/`set_local_description`, and returns
- Enforce `ChannelPermission::JOIN_VOICE` (and `SPEAK` for publishing) before creating a `PeerConnection`, reusing the `computed_permission` lookup pattern from `src/services/realtime_registry.rs`. the answer SDP.
- Implement `VoiceService::handle_ice_candidate` and `VoiceService::leave`, closing and removing the `PeerConnection`
from its room.
- Enforce `ChannelPermission::JOIN_VOICE` (and `SPEAK` for publishing) before creating a `PeerConnection`, reusing the
`computed_permission` lookup pattern from `src/services/realtime_registry.rs`.
### Step 3: Wire SFU track forwarding between peers in the same channel ### Step 3: Wire SFU track forwarding between peers in the same channel
Audio published by one connected client in a voice channel is forwarded by the server to every other client connected to the same channel.
Audio published by one connected client in a voice channel is forwarded by the server to every other client connected to
the same channel.
- On `PeerConnection::on_track` for a given user's connection, register the inbound track on the owning `VoiceRoom`. - On `PeerConnection::on_track` for a given user's connection, register the inbound track on the owning `VoiceRoom`.
- For every other `PeerConnection` already in that `VoiceRoom`, add and forward the new track (SFU fan-out) using `rustrtc`'s track/`MediaCapabilities` APIs. - For every other `PeerConnection` already in that `VoiceRoom`, add and forward the new track (SFU fan-out) using
`rustrtc`'s track/`MediaCapabilities` APIs.
- When a new peer joins an existing room, subscribe it to all tracks already being forwarded by other room members. - When a new peer joins an existing room, subscribe it to all tracks already being forwarded by other room members.
- On `leave` or disconnect, remove the peer's published track from all other peers' forwarding sets and close its `PeerConnection`. - On `leave` or disconnect, remove the peer's published track from all other peers' forwarding sets and close its
`PeerConnection`.
### Step 4: Expose Voice signaling through the existing WebSocket gateway ### Step 4: Expose Voice signaling through the existing WebSocket gateway
Clients can perform the full offer/answer/ICE handshake over the existing gateway WebSocket connection using a new `Voice` namespace, with no new HTTP/WS endpoint introduced.
- Extend `GatewayClient::on_message` in `src/routes/gateway/mod.rs` to parse `GatewayEvent{namespace: "Voice", action, content}` messages (`offer`, `answer`, `ice-candidate`, `leave`). Clients can perform the full offer/answer/ICE handshake over the existing gateway WebSocket connection using a new
- Dispatch parsed messages to `VoiceService` (accessed via `AppState`) and send the resulting answer/ICE-candidate events back through the client's existing `mpsc::UnboundedSender<Message>`. `Voice` namespace, with no new HTTP/WS endpoint introduced.
- On gateway disconnect (`GatewayClient::on_disconnect`), call `VoiceService::leave` for any channel the user was actively connected to in voice.
- Update `config.toml`'s `DEFAULT_CONFIG_TOML` comment for `udp_port` to reflect its new role as the rustrtc ICE/media mux port. - Extend `GatewayClient::on_message` in `src/routes/gateway/mod.rs` to parse
`GatewayEvent{namespace: "Voice", action, content}` messages (`offer`, `answer`, `ice-candidate`, `leave`).
- Dispatch parsed messages to `VoiceService` (accessed via `AppState`) and send the resulting answer/ICE-candidate
events back through the client's existing `mpsc::UnboundedSender<Message>`.
- On gateway disconnect (`GatewayClient::on_disconnect`), call `VoiceService::leave` for any channel the user was
actively connected to in voice.
- Update `config.toml`'s `DEFAULT_CONFIG_TOML` comment for `udp_port` to reflect its new role as the rustrtc ICE/media
mux port.
+155 -53
View File
@@ -5,66 +5,132 @@ sessionId: session-260912-101455-1pxv
# Requirements # Requirements
### Overview & Goals ### Overview & Goals
Replace the current placeholder raw-UDP voice relay (`src/udp/*`) with a real WebRTC media stack built on the `rustrtc` crate (already declared in `Cargo.toml`). The server becomes a centralized **SFU** (Selective Forwarding Unit): each connected client opens **one `PeerConnection`** with the server, the server decrypts/receives that client's audio (and later video) track and re-encodes/forwards it to every other member of the same voice `Channel`.
Replace the current placeholder raw-UDP voice relay (`src/udp/*`) with a real WebRTC media stack built on the `rustrtc`
crate (already declared in `Cargo.toml`). The server becomes a centralized **SFU** (Selective Forwarding Unit): each
connected client opens **one `PeerConnection`** with the server, the server decrypts/receives that client's audio (and
later video) track and re-encodes/forwards it to every other member of the same voice `Channel`.
### Scope ### Scope
**In Scope** **In Scope**
- Remove `src/udp/server.rs`, `src/udp/router.rs` raw-socket logic (metrics module is kept/adapted). - Remove `src/udp/server.rs`, `src/udp/router.rs` raw-socket logic (metrics module is kept/adapted).
- Add a `voice` module hosting: `PeerConnection` lifecycle management via `rustrtc`, SDP offer/answer exchange, ICE candidate exchange, and SFU-style track forwarding scoped by `channel_id`. - Add a `voice` module hosting: `PeerConnection` lifecycle management via `rustrtc`, SDP offer/answer exchange, ICE
- Reuse the existing WebSocket gateway (`GatewayManager` / `RealtimeRouter` / `on_message` in `src/routes/gateway/mod.rs`) as the signaling transport — add a `Voice` namespace to the existing JSON event envelope (`GatewayEvent { namespace, action, content }`) instead of introducing a new endpoint, per explicit request to avoid breaking the current structure. candidate exchange, and SFU-style track forwarding scoped by `channel_id`.
- Reuse `channel::ChannelType::Voice`, `channel_user`, and the `JOIN_VOICE` / `SPEAK` permission bits already defined in `src/permissions.rs` to authorize who may join/publish in a voice channel. - Reuse the existing WebSocket gateway (`GatewayManager` / `RealtimeRouter` / `ws_on_message` in
- Update `config.toml` / `NetworkConfig` so the single `udp_port` continues to be the one UDP port used, now bound through `rustrtc`'s `ice_udp_mux` (single-port ICE) instead of a raw `UdpSocket`. `src/routes/gateway/mod.rs`) as the signaling transport — add a `Voice` namespace to the existing JSON event envelope
- Update `AppMetrics`/`core::mod::App::run` wiring so the new voice service starts/stops alongside the HTTP server, replacing `UdpServer`. (`GatewayEvent { namespace, action, content }`) instead of introducing a new endpoint, per explicit request to avoid
breaking the current structure.
- Reuse `channel::ChannelType::Voice`, `channel_user`, and the `JOIN_VOICE` / `SPEAK` permission bits already defined in
`src/permissions.rs` to authorize who may join/publish in a voice channel.
- Update `config.toml` / `NetworkConfig` so the single `udp_port` continues to be the one UDP port used, now bound
through `rustrtc`'s `ice_udp_mux` (single-port ICE) instead of a raw `UdpSocket`.
- Update `AppMetrics`/`core::mod::App::run` wiring so the new voice service starts/stops alongside the HTTP server,
replacing `UdpServer`.
**Out of Scope** **Out of Scope**
- TURN/STUN server configuration (kept minimal per user's answer — LAN/local network scenario for now). - TURN/STUN server configuration (kept minimal per user's answer — LAN/local network scenario for now).
- Video/screen-share specific handling (structure will allow it later, but only audio forwarding is implemented now). - Video/screen-share specific handling (structure will allow it later, but only audio forwarding is implemented now).
- Client-side (frontend) implementation details beyond the signaling contract needed by the server. - Client-side (frontend) implementation details beyond the signaling contract needed by the server.
### User Stories ### User Stories
- As a user with `JOIN_VOICE` permission on a voice channel, I want to connect and have my microphone audio heard by other members of that same channel.
- As a user already in a voice channel, I want to hear every other member's audio mixed/forwarded by the server without opening a direct connection to each peer. - As a user with `JOIN_VOICE` permission on a voice channel, I want to connect and have my microphone audio heard by
- As a server operator, I want the previous ad-hoc UDP relay removed and replaced by a maintained WebRTC stack (rustrtc) so voice traffic is encrypted (SRTP) and NAT-traversal (ICE) works out of the box. other members of that same channel.
- As a user already in a voice channel, I want to hear every other member's audio mixed/forwarded by the server without
opening a direct connection to each peer.
- As a server operator, I want the previous ad-hoc UDP relay removed and replaced by a maintained WebRTC stack (rustrtc)
so voice traffic is encrypted (SRTP) and NAT-traversal (ICE) works out of the box.
### Functional Requirements ### Functional Requirements
- A client sends an SDP offer through the gateway (`namespace: "Voice"`), scoped to a `channel_id`; the server validates channel membership/permissions, creates a `rustrtc::PeerConnection`, and returns an SDP answer through the same gateway connection.
- A client sends an SDP offer through the gateway (`namespace: "Voice"`), scoped to a `channel_id`; the server validates
channel membership/permissions, creates a `rustrtc::PeerConnection`, and returns an SDP answer through the same
gateway connection.
- ICE candidates generated by either side are exchanged as additional `Voice` gateway events. - ICE candidates generated by either side are exchanged as additional `Voice` gateway events.
- Once connected, the server subscribes the client's inbound audio track to every other `PeerConnection` currently joined to the same voice channel (SFU fan-out), and adds new joiners' tracks to previously-connected peers as they arrive. - Once connected, the server subscribes the client's inbound audio track to every other `PeerConnection` currently
- When a user disconnects (gateway socket closes) or leaves the voice channel, their `PeerConnection` is closed and their track is removed from all other peers' forwarding sets. joined to the same voice channel (SFU fan-out), and adds new joiners' tracks to previously-connected peers as they
- Existing `UdpMetrics`-style counters (packets/bytes in/out, errors) are preserved in spirit, adapted to whatever counters `rustrtc` exposes (or wrapped manually) for the periodic `reporter`. arrive.
- When a user disconnects (gateway socket closes) or leaves the voice channel, their `PeerConnection` is closed and
their track is removed from all other peers' forwarding sets.
- Existing `UdpMetrics`-style counters (packets/bytes in/out, errors) are preserved in spirit, adapted to whatever
counters `rustrtc` exposes (or wrapped manually) for the periodic `reporter`.
# Technical Design # Technical Design
### Current Implementation ### Current Implementation
- `src/udp/server.rs`: binds a raw `tokio::net::UdpSocket` on `NetworkConfig.udp_port`, loops on `recv_from`, and blindly forwards every datagram to all peers registered in a `RoutingTable` under a **hard-coded** `"default"` channel — there is no real protocol, no per-voice-channel isolation, no encryption.
- `src/udp/router.rs`: `RoutingTable` maps `ChannelId -> Vec<SocketAddr>`; `join`/`leave`/`routing_table_mut()` exist but are **never called** anywhere in the codebase — dead placeholder API. - `src/udp/server.rs`: binds a raw `tokio::net::UdpSocket` on `NetworkConfig.udp_port`, loops on `recv_from`, and
- `src/udp/metrics.rs`: atomic counters (`packets_received`, `bytes_sent`, …) + `spawn_reporter` logging every interval. Plugged into `AppMetrics.udp` (`src/metrics/mod.rs`). blindly forwards every datagram to all peers registered in a `RoutingTable` under a **hard-coded** `"default"`
- `src/core/mod.rs`: `App::build`/`App::run` construct `UdpServer::new(&config.network, udp_metrics)` and `tokio::spawn(udp_server.run())`, joined into the same `tokio::select!`/shutdown-broadcast pattern as `HttpServer`. channel — there is no real protocol, no per-voice-channel isolation, no encryption.
- `src/routes/gateway/mod.rs`: `GatewayManager` keeps `ConnectionKey{user_id, connection_id} -> GatewayClient{sender: mpsc::UnboundedSender<Message>}`; events are pushed as JSON `GatewayEvent{namespace, action, content}`. `GatewayClient::on_message` currently just logs incoming text — this is the extension point for voice signaling. - `src/udp/router.rs`: `RoutingTable` maps `ChannelId -> Vec<SocketAddr>`; `join`/`leave`/`routing_table_mut()` exist
- `src/services/realtime_registry.rs`: already maintains `channel_id -> HashSet<user_id>` membership computed from `computed_permission` (READ_CHANNEL) — reusable to know who is allowed in a channel, but voice-specific "currently connected to voice" state does not exist yet and must be tracked separately (WebSocket presence in a channel != actively broadcasting audio). but are **never called** anywhere in the codebase — dead placeholder API.
- `src/permissions.rs`: `JOIN_VOICE`, `SPEAK`, `STREAM`, `MOVE_OTHERS`, `DISCONNECT_OTHERS`, `MANAGE_VOICE_CHANNEL` bits already defined but unused by any voice logic today. - `src/udp/metrics.rs`: atomic counters (`packets_received`, `bytes_sent`, …) + `spawn_reporter` logging every interval.
Plugged into `AppMetrics.udp` (`src/metrics/mod.rs`).
- `src/core/mod.rs`: `App::build`/`App::run` construct `UdpServer::new(&config.network, udp_metrics)` and
`tokio::spawn(udp_server.run())`, joined into the same `tokio::select!`/shutdown-broadcast pattern as `HttpServer`.
- `src/routes/gateway/mod.rs`: `GatewayManager` keeps
`ConnectionKey{user_id, connection_id} -> GatewayClient{sender: mpsc::UnboundedSender<Message>}`; events are pushed as
JSON `GatewayEvent{namespace, action, content}`. `GatewayClient::on_message` currently just logs incoming text — this
is the extension point for voice signaling.
- `src/services/realtime_registry.rs`: already maintains `channel_id -> HashSet<user_id>` membership computed from
`computed_permission` (READ_CHANNEL) — reusable to know who is allowed in a channel, but voice-specific "currently
connected to voice" state does not exist yet and must be tracked separately (WebSocket presence in a channel !=
actively broadcasting audio).
- `src/permissions.rs`: `JOIN_VOICE`, `SPEAK`, `STREAM`, `MOVE_OTHERS`, `DISCONNECT_OTHERS`, `MANAGE_VOICE_CHANNEL` bits
already defined but unused by any voice logic today.
- `src/models/channel.rs`: `ChannelType::Voice` variant already exists. - `src/models/channel.rs`: `ChannelType::Voice` variant already exists.
### Key Decisions ### Key Decisions
1. **Topology: centralized SFU** (confirmed by user) — one `rustrtc::PeerConnection` per connected client per voice channel; the server forwards each publisher's decoded RTP track to every other subscriber in that channel. No client-to-client PeerConnections.
2. **Signaling transport: existing WebSocket gateway** (confirmed by user, to avoid restructuring) — SDP offer/answer and ICE candidates are carried as new `Voice` namespace events inside the current `GatewayEvent` envelope, handled in `GatewayClient::on_message` (currently a stub) and dispatched to a new `VoiceService`. 1. **Topology: centralized SFU** (confirmed by user) — one `rustrtc::PeerConnection` per connected client per voice
3. **Single UDP port reuse** — `rustrtc`'s `RtcConfiguration` will be set up with `ice_udp_mux = true` and bound to the existing `NetworkConfig.udp_port`, so the media/ICE traffic keeps using the same single port previously owned by the raw `UdpServer`, minimizing config/infra changes (firewall rules, `config.toml` stay compatible). channel; the server forwards each publisher's decoded RTP track to every other subscriber in that channel. No
4. **No TURN/STUN for now** (confirmed by user) — `RtcConfiguration.ice_servers` left empty/default; can be added later via `config.toml` without further architecture changes. client-to-client PeerConnections.
5. **Voice presence tracked separately from `RealtimeRegistry`** — a new lightweight in-memory registry (`VoiceRoom` per `channel_id`) tracks which `PeerConnection`s are actively publishing/subscribing in a voice channel, since being subscribed to gateway events (`RealtimeRegistry`) is not the same as being connected to voice media. 2. **Signaling transport: existing WebSocket gateway** (confirmed by user, to avoid restructuring) — SDP offer/answer
and ICE candidates are carried as new `Voice` namespace events inside the current `GatewayEvent` envelope, handled in
`GatewayClient::on_message` (currently a stub) and dispatched to a new `VoiceService`.
3. **Single UDP port reuse** — `rustrtc`'s `RtcConfiguration` will be set up with `ice_udp_mux = true` and bound to the
existing `NetworkConfig.udp_port`, so the media/ICE traffic keeps using the same single port previously owned by the
raw `UdpServer`, minimizing config/infra changes (firewall rules, `config.toml` stay compatible).
4. **No TURN/STUN for now** (confirmed by user) — `RtcConfiguration.ice_servers` left empty/default; can be added later
via `config.toml` without further architecture changes.
5. **Voice presence tracked separately from `RealtimeRegistry`** — a new lightweight in-memory registry (`VoiceRoom` per
`channel_id`) tracks which `PeerConnection`s are actively publishing/subscribing in a voice channel, since being
subscribed to gateway events (`RealtimeRegistry`) is not the same as being connected to voice media.
### Proposed Changes ### Proposed Changes
- Replace the `src/udp` module with a new `src/voice` module: - Replace the `src/udp` module with a new `src/voice` module:
- `voice/mod.rs`: public API, exports `VoiceService`. - `voice/mod.rs`: public API, exports `VoiceService`.
- `voice/service.rs`: `VoiceService` owns a `rustrtc::RtcConfiguration` (built from `NetworkConfig`), a map `channel_id -> VoiceRoom`, and methods `handle_offer(user_id, channel_id, sdp) -> answer_sdp`, `handle_ice_candidate(...)`, `leave(user_id, channel_id)`. - `voice/service.rs`: `VoiceService` owns a `rustrtc::RtcConfiguration` (built from `NetworkConfig`), a map
- `voice/room.rs`: `VoiceRoom` holds `HashMap<user_id, Arc<PeerConnection>>` for one channel; implements track fan-out — on receiving a remote track from peer A, it calls into every other peer's `PeerConnection` to add/forward that track (rustrtc `PeerConnection` API, per its `Usage` example: create connection, `set_remote_description`, `create_answer`, `set_local_description`, subscribe to `on_track`). `channel_id -> VoiceRoom`, and methods `handle_offer(user_id, channel_id, sdp) -> answer_sdp`,
- `voice/metrics.rs`: keep counters analogous to today's `UdpMetrics` (packets/bytes/errors), fed by hooks around track forwarding, still exposed through `AppMetrics.udp` (renamed `AppMetrics.voice` where feasible) and the existing `reporter::spawn_reporter` cadence. `handle_ice_candidate(...)`, `leave(user_id, channel_id)`.
- Extend `GatewayEvent` handling: add `"Voice"` namespace with actions like `offer`, `answer`, `ice-candidate`, `leave`; `GatewayClient::on_message` parses these and calls `VoiceService` through `AppState`, then pushes the answer/ICE-candidate response back on the same `mpsc::UnboundedSender<Message>` used for all other gateway events (no protocol reinvention). - `voice/room.rs`: `VoiceRoom` holds `HashMap<user_id, Arc<PeerConnection>>` for one channel; implements track
- Permission check on `offer`: verify the user has `ChannelPermission::JOIN_VOICE` (and `SPEAK` to publish) on the target channel, reusing the same `computed_permission` lookups already used by `RealtimeRegistry`/`http/permissions.rs`. fan-out — on receiving a remote track from peer A, it calls into every other peer's `PeerConnection` to
- Update `src/core/mod.rs`: drop `UdpServer::new` / `udp_server.run()` / `udp_shutdown_tx`; construct `VoiceService` instead and store it in `AppState` (alongside `gateway`, `services`) so gateway handlers can call it; no UDP socket bind/run task is spawned by `App::run` anymore — `rustrtc` manages its own I/O internally once configured. add/forward that track (rustrtc `PeerConnection` API, per its `Usage` example: create connection,
- Update `src/config.rs`: keep `NetworkConfig.udp_port` (renamed conceptually to "voice/media port" in comments) feeding `rustrtc`'s `ice_udp_mux_port`; update `DEFAULT_CONFIG_TOML` comment accordingly. `set_remote_description`, `create_answer`, `set_local_description`, subscribe to `on_track`).
- Remove `src/udp/` directory entirely once `voice/` fully replaces its responsibilities; update `Cargo.toml`/module declarations (`lib.rs`) accordingly. - `voice/metrics.rs`: keep counters analogous to today's `UdpMetrics` (packets/bytes/errors), fed by hooks around
track forwarding, still exposed through `AppMetrics.udp` (renamed `AppMetrics.voice` where feasible) and the
existing `reporter::spawn_reporter` cadence.
- Extend `GatewayEvent` handling: add `"Voice"` namespace with actions like `offer`, `answer`, `ice-candidate`, `leave`;
`GatewayClient::on_message` parses these and calls `VoiceService` through `AppState`, then pushes the
answer/ICE-candidate response back on the same `mpsc::UnboundedSender<Message>` used for all other gateway events (no
protocol reinvention).
- Permission check on `offer`: verify the user has `ChannelPermission::JOIN_VOICE` (and `SPEAK` to publish) on the
target channel, reusing the same `computed_permission` lookups already used by `RealtimeRegistry`/
`http/permissions.rs`.
- Update `src/core/mod.rs`: drop `UdpServer::new` / `udp_server.run()` / `udp_shutdown_tx`; construct `VoiceService`
instead and store it in `AppState` (alongside `gateway`, `services`) so gateway handlers can call it; no UDP socket
bind/run task is spawned by `App::run` anymore — `rustrtc` manages its own I/O internally once configured.
- Update `src/config.rs`: keep `NetworkConfig.udp_port` (renamed conceptually to "voice/media port" in comments) feeding
`rustrtc`'s `ice_udp_mux_port`; update `DEFAULT_CONFIG_TOML` comment accordingly.
- Remove `src/udp/` directory entirely once `voice/` fully replaces its responsibilities; update `Cargo.toml`/module
declarations (`lib.rs`) accordingly.
### Data Models / Contracts ### Data Models / Contracts
```rust ```rust
// Gateway JSON contract additions (namespace = "Voice") // Gateway JSON contract additions (namespace = "Voice")
{ "namespace": "Voice", "action": "offer", "content": { "channel_id": Uuid, "sdp": String } } { "namespace": "Voice", "action": "offer", "content": { "channel_id": Uuid, "sdp": String } }
@@ -72,6 +138,7 @@ Replace the current placeholder raw-UDP voice relay (`src/udp/*`) with a real We
{ "namespace": "Voice", "action": "ice-candidate", "content": { "channel_id": Uuid, "candidate": String } } { "namespace": "Voice", "action": "ice-candidate", "content": { "channel_id": Uuid, "candidate": String } }
{ "namespace": "Voice", "action": "leave", "content": { "channel_id": Uuid } } { "namespace": "Voice", "action": "leave", "content": { "channel_id": Uuid } }
``` ```
```rust ```rust
pub struct VoiceService { pub struct VoiceService {
config: rustrtc::RtcConfiguration, config: rustrtc::RtcConfiguration,
@@ -86,6 +153,7 @@ impl VoiceService {
``` ```
### Architecture Diagram ### Architecture Diagram
```mermaid ```mermaid
graph TD graph TD
ClientA[Client A - browser] -- WebSocket gateway: Voice offer/answer/ICE --> Gateway[GatewayManager / on_message] ClientA[Client A - browser] -- WebSocket gateway: Voice offer/answer/ICE --> Gateway[GatewayManager / on_message]
@@ -101,38 +169,72 @@ graph TD
``` ```
### Risks ### Risks
- `rustrtc` is a young/fast-moving crate (frequent point releases per the benchmark notes found) — API surface for track-forwarding/SFU usage should be validated against the pinned `0.3.133` docs during implementation; if a needed primitive (e.g., explicit track re-publishing helper) is missing, a thin adapter layer will be needed inside `voice/room.rs`.
- Moving from "no real protocol" to full SDP/ICE negotiation is a larger surface than the previous placeholder; permission checks (`JOIN_VOICE`/`SPEAK`) must be enforced before any `PeerConnection` is created to avoid unauthorized channel joins. - `rustrtc` is a young/fast-moving crate (frequent point releases per the benchmark notes found) — API surface for
- Since signaling now flows through the same WebSocket used for all other realtime events, malformed/large SDP payloads must not block the `GatewayClient` message loop — the voice service calls will be dispatched without blocking other event types. track-forwarding/SFU usage should be validated against the pinned `0.3.133` docs during implementation; if a needed
primitive (e.g., explicit track re-publishing helper) is missing, a thin adapter layer will be needed inside
`voice/room.rs`.
- Moving from "no real protocol" to full SDP/ICE negotiation is a larger surface than the previous placeholder;
permission checks (`JOIN_VOICE`/`SPEAK`) must be enforced before any `PeerConnection` is created to avoid unauthorized
channel joins.
- Since signaling now flows through the same WebSocket used for all other realtime events, malformed/large SDP payloads
must not block the `GatewayClient` message loop — the voice service calls will be dispatched without blocking other
event types.
# Delivery Steps # Delivery Steps
### * Step 1: Scaffold the voice module and RtcConfiguration from NetworkConfig ### * Step 1: Scaffold the voice module and RtcConfiguration from NetworkConfig
A new `src/voice` module exists with a `VoiceService` capable of building a `rustrtc::RtcConfiguration` from the app's network config, replacing the old `src/udp` module's role in `core/mod.rs`.
A new `src/voice` module exists with a `VoiceService` capable of building a `rustrtc::RtcConfiguration` from the app's
network config, replacing the old `src/udp` module's role in `core/mod.rs`.
- Create `src/voice/mod.rs`, `src/voice/service.rs`, `src/voice/room.rs`, `src/voice/metrics.rs`. - Create `src/voice/mod.rs`, `src/voice/service.rs`, `src/voice/room.rs`, `src/voice/metrics.rs`.
- Port `UdpMetrics`-style counters into `voice/metrics.rs` (packets/bytes in/out, errors), keeping the `Metrics`/`MetricsSnapshot` trait implementations used by `crate::metrics::reporter`. - Port `UdpMetrics`-style counters into `voice/metrics.rs` (packets/bytes in/out, errors), keeping the `Metrics`/
- Build `RtcConfiguration`/`RtcConfigurationBuilder` in `VoiceService::new(&NetworkConfig, metrics)` using `ice_udp_mux = true` and `ice_udp_mux_port = network.udp_port`, no ICE servers configured. `MetricsSnapshot` trait implementations used by `crate::metrics::reporter`.
- Remove `src/udp/server.rs` and `src/udp/router.rs`; delete unused `RoutingTable` (dead code confirmed unused elsewhere). - Build `RtcConfiguration`/`RtcConfigurationBuilder` in `VoiceService::new(&NetworkConfig, metrics)` using
- Update `src/core/mod.rs` (`App::build`/`App::run`) to construct `VoiceService` instead of `UdpServer`, store it on `AppState`, and drop the now-unneeded `udp_shutdown_tx`/`udp_handle` spawn/join wiring. `ice_udp_mux = true` and `ice_udp_mux_port = network.udp_port`, no ICE servers configured.
- Remove `src/udp/server.rs` and `src/udp/router.rs`; delete unused `RoutingTable` (dead code confirmed unused
elsewhere).
- Update `src/core/mod.rs` (`App::build`/`App::run`) to construct `VoiceService` instead of `UdpServer`, store it on
`AppState`, and drop the now-unneeded `udp_shutdown_tx`/`udp_handle` spawn/join wiring.
- Update `src/metrics/mod.rs` (`AppMetrics`) to reference the new voice metrics type. - Update `src/metrics/mod.rs` (`AppMetrics`) to reference the new voice metrics type.
### Step 2: Implement per-channel PeerConnection lifecycle in VoiceRoom ### Step 2: Implement per-channel PeerConnection lifecycle in VoiceRoom
`VoiceService`/`VoiceRoom` can accept an SDP offer for a given channel, create a `rustrtc::PeerConnection`, and return an SDP answer, tracking connections per `channel_id`.
`VoiceService`/`VoiceRoom` can accept an SDP offer for a given channel, create a `rustrtc::PeerConnection`, and return
an SDP answer, tracking connections per `channel_id`.
- Implement `VoiceRoom` (`channel_id -> HashMap<user_id, Arc<PeerConnection>>`). - Implement `VoiceRoom` (`channel_id -> HashMap<user_id, Arc<PeerConnection>>`).
- Implement `VoiceService::handle_offer(user_id, channel_id, sdp)`: looks up/creates the room, creates a `PeerConnection` via `rustrtc`, calls `set_remote_description`/`create_answer`/`set_local_description`, returns the answer SDP. - Implement `VoiceService::handle_offer(user_id, channel_id, sdp)`: looks up/creates the room, creates a
- Implement `VoiceService::handle_ice_candidate` and `VoiceService::leave`, closing and removing the `PeerConnection` from its room. `PeerConnection` via `rustrtc`, calls `set_remote_description`/`create_answer`/`set_local_description`, returns the
- Enforce `ChannelPermission::JOIN_VOICE` (and `SPEAK` for publishing) before creating a `PeerConnection`, reusing the `computed_permission` lookup pattern from `src/services/realtime_registry.rs`. answer SDP.
- Implement `VoiceService::handle_ice_candidate` and `VoiceService::leave`, closing and removing the `PeerConnection`
from its room.
- Enforce `ChannelPermission::JOIN_VOICE` (and `SPEAK` for publishing) before creating a `PeerConnection`, reusing the
`computed_permission` lookup pattern from `src/services/realtime_registry.rs`.
### Step 3: Wire SFU track forwarding between peers in the same channel ### Step 3: Wire SFU track forwarding between peers in the same channel
Audio published by one connected client in a voice channel is forwarded by the server to every other client connected to the same channel.
Audio published by one connected client in a voice channel is forwarded by the server to every other client connected to
the same channel.
- On `PeerConnection::on_track` for a given user's connection, register the inbound track on the owning `VoiceRoom`. - On `PeerConnection::on_track` for a given user's connection, register the inbound track on the owning `VoiceRoom`.
- For every other `PeerConnection` already in that `VoiceRoom`, add/forward the new track (SFU fan-out) using `rustrtc`'s track/`MediaCapabilities` APIs. - For every other `PeerConnection` already in that `VoiceRoom`, add/forward the new track (SFU fan-out) using `rustrtc`
's track/`MediaCapabilities` APIs.
- When a new peer joins an existing room, subscribe it to all tracks already being forwarded by other room members. - When a new peer joins an existing room, subscribe it to all tracks already being forwarded by other room members.
- On `leave`/disconnect, remove the peer's published track from all other peers' forwarding sets and close its `PeerConnection`. - On `leave`/disconnect, remove the peer's published track from all other peers' forwarding sets and close its
`PeerConnection`.
### Step 4: Expose Voice signaling through the existing WebSocket gateway ### Step 4: Expose Voice signaling through the existing WebSocket gateway
Clients can perform the full offer/answer/ICE handshake over the existing gateway WebSocket connection using a new `Voice` namespace, with no new HTTP/WS endpoint introduced.
- Extend `GatewayClient::on_message` in `src/routes/gateway/mod.rs` to parse `GatewayEvent{namespace: "Voice", action, content}` messages (`offer`, `answer`, `ice-candidate`, `leave`). Clients can perform the full offer/answer/ICE handshake over the existing gateway WebSocket connection using a new
- Dispatch parsed messages to `VoiceService` (accessed via `AppState`) and send the resulting answer/ICE-candidate events back through the client's existing `mpsc::UnboundedSender<Message>`. `Voice` namespace, with no new HTTP/WS endpoint introduced.
- On gateway disconnect (`GatewayClient::on_disconnect`), call `VoiceService::leave` for any channel the user was actively connected to in voice.
- Update `config.toml`'s `DEFAULT_CONFIG_TOML` comment for `udp_port` to reflect its new role as the rustrtc ICE/media mux port. - Extend `GatewayClient::on_message` in `src/routes/gateway/mod.rs` to parse
`GatewayEvent{namespace: "Voice", action, content}` messages (`offer`, `answer`, `ice-candidate`, `leave`).
- Dispatch parsed messages to `VoiceService` (accessed via `AppState`) and send the resulting answer/ICE-candidate
events back through the client's existing `mpsc::UnboundedSender<Message>`.
- On gateway disconnect (`GatewayClient::on_disconnect`), call `VoiceService::leave` for any channel the user was
actively connected to in voice.
- Update `config.toml`'s `DEFAULT_CONFIG_TOML` comment for `udp_port` to reflect its new role as the rustrtc ICE/media
mux port.
Generated
+146 -16
View File
@@ -189,6 +189,15 @@ dependencies = [
"derive_arbitrary", "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]] [[package]]
name = "argon2" name = "argon2"
version = "0.6.0" version = "0.6.0"
@@ -704,6 +713,28 @@ dependencies = [
"tracing", "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]] [[package]]
name = "base16ct" name = "base16ct"
version = "0.2.0" version = "0.2.0"
@@ -1753,6 +1784,16 @@ dependencies = [
"percent-encoding", "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]] [[package]]
name = "fs_extra" name = "fs_extra"
version = "1.3.0" version = "1.3.0"
@@ -1992,6 +2033,25 @@ dependencies = [
"tracing", "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]] [[package]]
name = "half" name = "half"
version = "2.7.1" version = "2.7.1"
@@ -2191,7 +2251,7 @@ dependencies = [
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"futures-util", "futures-util",
"h2", "h2 0.3.27",
"http 0.2.12", "http 0.2.12",
"http-body 0.4.6", "http-body 0.4.6",
"httparse", "httparse",
@@ -2215,6 +2275,7 @@ dependencies = [
"bytes", "bytes",
"futures-channel", "futures-channel",
"futures-core", "futures-core",
"h2 0.4.19",
"http 1.5.0", "http 1.5.0",
"http-body 1.1.0", "http-body 1.1.0",
"httparse", "httparse",
@@ -2495,9 +2556,9 @@ dependencies = [
[[package]] [[package]]
name = "jsonwebtoken" name = "jsonwebtoken"
version = "11.0.0" version = "11.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "881733cbc631fc9e472e24447ce32a64bedf2da498d6d8570b08edc87de71f65" checksum = "e75fe14a82d81e5f5af639997db37d8b96045938a7ac6ab18cdbe1c7467e05e1"
dependencies = [ dependencies = [
"aws-lc-rs", "aws-lc-rs",
"base64 0.22.1", "base64 0.22.1",
@@ -2957,6 +3018,7 @@ dependencies = [
"async-trait", "async-trait",
"axum", "axum",
"axum-extra", "axum-extra",
"axum-server",
"bitflags", "bitflags",
"chrono", "chrono",
"config", "config",
@@ -2967,6 +3029,7 @@ dependencies = [
"log", "log",
"migration", "migration",
"parking_lot", "parking_lot",
"rcgen 0.13.2",
"rustrtc", "rustrtc",
"sea-orm", "sea-orm",
"serde", "serde",
@@ -3482,6 +3545,19 @@ dependencies = [
"crossbeam-utils", "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]] [[package]]
name = "rcgen" name = "rcgen"
version = "0.14.10" version = "0.14.10"
@@ -3493,7 +3569,7 @@ dependencies = [
"rustls-pki-types", "rustls-pki-types",
"time", "time",
"x509-parser", "x509-parser",
"yasna", "yasna 0.6.0",
] ]
[[package]] [[package]]
@@ -3703,6 +3779,29 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "rustls-pki-types" name = "rustls-pki-types"
version = "1.15.1" version = "1.15.1"
@@ -3713,10 +3812,22 @@ dependencies = [
] ]
[[package]] [[package]]
name = "rustrtc" name = "rustls-webpki"
version = "0.3.133" version = "0.103.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d8307edee45813b075a3a7dd237ab68f41d7220c53cf2752802431f68c055db" checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
dependencies = [
"aws-lc-rs",
"ring",
"rustls-pki-types",
"untrusted 0.9.0",
]
[[package]]
name = "rustrtc"
version = "0.3.138"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282d086d81cf8908a2c3d6ecf068180b711a20f0412658da13f052608f00d936"
dependencies = [ dependencies = [
"aes 0.9.3", "aes 0.9.3",
"aes-gcm", "aes-gcm",
@@ -3735,7 +3846,7 @@ dependencies = [
"p256", "p256",
"parking_lot", "parking_lot",
"rand 0.10.2", "rand 0.10.2",
"rcgen", "rcgen 0.14.10",
"serde", "serde",
"serde_json", "serde_json",
"sha1 0.11.0", "sha1 0.11.0",
@@ -3787,9 +3898,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm" name = "sea-orm"
version = "2.0.2" version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a334e83ced3ae3ee44db0f84d1fcf8d2087a1ad9bb9036f00f9f6067156ea197" checksum = "e01d46a6e22b8ce84aba64fe97011669859bc7f7120a47f5b58839dcaaa4545c"
dependencies = [ dependencies = [
"async-stream", "async-stream",
"async-trait", "async-trait",
@@ -3835,9 +3946,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm-cli" name = "sea-orm-cli"
version = "2.0.2" version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5a53505884d7c907bcf4f7b4ddb1b29425e62fef8b98aea9c99e17781cceb798" checksum = "2479056c3f9c96c8ec5b9091103050b5f3a0532c615d8ab01d3edb81af5c65a8"
dependencies = [ dependencies = [
"chrono", "chrono",
"clap", "clap",
@@ -3852,9 +3963,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm-macros" name = "sea-orm-macros"
version = "2.0.2" version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4039a86f9acc4d3b52747508b347dddc6fd725bbc429902ebeb6d26225fc2528" checksum = "e24c82fc1e76c014dffe5ecdac8f654da73161d703a16ea13414581e07459c83"
dependencies = [ dependencies = [
"heck 0.5.0", "heck 0.5.0",
"itertools 0.14.0", "itertools 0.14.0",
@@ -3868,9 +3979,9 @@ dependencies = [
[[package]] [[package]]
name = "sea-orm-migration" name = "sea-orm-migration"
version = "2.0.2" version = "2.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bd09adbef87100d07131a60a8c5508b53d0cf2136521f654aae47af5e6a097fe" checksum = "9acbadc98b17aa94657122dae4b5a840c7cebe6359a5ba15d8eaa233768942a5"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"clap", "clap",
@@ -4635,6 +4746,16 @@ dependencies = [
"syn 3.0.3", "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]] [[package]]
name = "tokio-stream" name = "tokio-stream"
version = "0.1.19" version = "0.1.19"
@@ -5446,6 +5567,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049"
[[package]]
name = "yasna"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e17bb3549cc1321ae1296b9cdc2698e2b6cb1992adfa19a8c72e5b7a738f44cd"
dependencies = [
"time",
]
[[package]] [[package]]
name = "yasna" name = "yasna"
version = "0.6.0" version = "0.6.0"
+5 -3
View File
@@ -15,7 +15,7 @@ tokio = { version = "1.53.1", features = ["full"] }
axum = { version = "0.8", features = ["ws", "multipart"] } axum = { version = "0.8", features = ["ws", "multipart"] }
axum-extra = { version = "0.12.6", features = ["cookie"] } axum-extra = { version = "0.12.6", features = ["cookie"] }
config = "0.15.25" config = "0.15.25"
sea-orm = { version = "2.0.2", features = ["sqlx-sqlite", "sqlx-postgres", "sqlx-mysql", "runtime-tokio", "with-chrono", "with-uuid", "with-json", "schema-sync"] } sea-orm = { version = "2.0.3", features = ["sqlx-sqlite", "sqlx-postgres", "sqlx-mysql", "runtime-tokio", "with-chrono", "with-uuid", "with-json", "schema-sync"] }
migration = { path = "migration" } migration = { path = "migration" }
event_bus = { path = "event_bus" } event_bus = { path = "event_bus" }
parking_lot = "0.12.5" parking_lot = "0.12.5"
@@ -31,7 +31,7 @@ utoipa-swagger-ui = { version = "9", features = ["axum"] }
log = "0.4" log = "0.4"
bitflags = "2.13.2" bitflags = "2.13.2"
argon2 = { version = "0.6.0", features = ["password-hash"] } argon2 = { version = "0.6.0", features = ["password-hash"] }
jsonwebtoken = { version = "11.0.0", features = ["aws_lc_rs"] } jsonwebtoken = { version = "11.1.0", features = ["aws_lc_rs"] }
tower = { version = "0.5", features = ["util"] } tower = { version = "0.5", features = ["util"] }
tower-http = { version = "0.7.1", features = ["catch-panic", "cors", "trace"] } tower-http = { version = "0.7.1", features = ["catch-panic", "cors", "trace"] }
chrono = "0.4.45" chrono = "0.4.45"
@@ -42,4 +42,6 @@ futures-util = "0.3"
form_urlencoded = "1.2.2" form_urlencoded = "1.2.2"
time = "0.3.55" time = "0.3.55"
sha2 = "0.11.0" sha2 = "0.11.0"
rustrtc = "0.3.133" rustrtc = "0.3.138"
axum-server = { version = "0.7", features = ["tls-rustls"] }
rcgen = "0.13"
+10
View File
@@ -7,6 +7,16 @@ host = "0.0.0.0"
tcp_port = 8080 tcp_port = 8080
# Voice/Video port # Voice/Video port
udp_port = 8080 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] [database]
# DSN for database # DSN for database
+1
View File
@@ -0,0 +1 @@
/target
+1 -1
View File
@@ -16,8 +16,8 @@ harness = false
tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] } tokio = { version = "1.53.1", default-features = false, features = ["rt", "sync"] }
parking_lot = "0.12.5" parking_lot = "0.12.5"
tracing = "0.1" tracing = "0.1"
uuid = { version = "1.26.1", features = ["v4"] }
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] } tokio = { version = "1.53.1", default-features = false, features = ["rt", "rt-multi-thread", "macros", "time", "sync"] }
criterion = { version = "0.8.2", features = ["async_tokio"] } criterion = { version = "0.8.2", features = ["async_tokio"] }
uuid = { version = "1.26.1", features = ["v4"] }
+148
View File
@@ -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
```
+68 -111
View File
@@ -1,3 +1,5 @@
#![allow(dead_code)]
use std::sync::{ use std::sync::{
Arc, Arc,
atomic::{AtomicU64, Ordering}, atomic::{AtomicU64, Ordering},
@@ -8,8 +10,6 @@ use criterion::{Criterion, Throughput, criterion_group, criterion_main};
use event_bus::EventBus; use event_bus::EventBus;
use tokio::runtime::Runtime; use tokio::runtime::Runtime;
const TOPIC: &str = "bench-topic";
#[derive(Clone)] #[derive(Clone)]
struct SmallEvent { struct SmallEvent {
value: u64, value: u64,
@@ -38,57 +38,46 @@ fn runtime() -> Runtime {
Runtime::new().expect("failed to create tokio runtime") Runtime::new().expect("failed to create tokio runtime")
} }
fn wait_until_received( async fn wait_until_received(received: &AtomicU64, expected: u64) {
received: &AtomicU64,
expected: u64,
) -> impl std::future::Future<Output = ()> + '_ {
async move {
while received.load(Ordering::Relaxed) < expected { while received.load(Ordering::Relaxed) < expected {
tokio::task::yield_now().await; tokio::task::yield_now().await;
} }
}
} }
fn bench_emit_no_subscriber(c: &mut Criterion) { 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)); group.throughput(Throughput::Elements(1));
let bus = EventBus::with_capacity(1024); let bus = EventBus::with_capacity(1024);
group.bench_function("u64", |b| { group.bench_function("u64", |b| {
b.iter(|| { b.iter(|| {
bus.emit(TOPIC, 42_u64); bus.emit(42_u64);
}); });
}); });
group.bench_function("small_struct", |b| { group.bench_function("small_struct", |b| {
b.iter(|| { b.iter(|| {
bus.emit(TOPIC, SmallEvent { value: 42 }); bus.emit(SmallEvent { value: 42 });
}); });
}); });
group.bench_function("string_struct", |b| { group.bench_function("string_struct", |b| {
b.iter(|| { b.iter(|| {
bus.emit( bus.emit(StringEvent {
TOPIC,
StringEvent {
id: 42, id: 42,
name: "Alice".to_string(), name: "Alice".to_string(),
message: "hello from benchmark".to_string(), message: "hello from benchmark".to_string(),
}, });
);
}); });
}); });
group.bench_function("vec_payload_1kb", |b| { group.bench_function("vec_payload_1kb", |b| {
b.iter(|| { b.iter(|| {
bus.emit( bus.emit(VecEvent {
TOPIC,
VecEvent {
id: 42, id: 42,
payload: vec![7_u8; 1024], payload: vec![7_u8; 1024],
}, });
);
}); });
}); });
@@ -96,13 +85,10 @@ fn bench_emit_no_subscriber(c: &mut Criterion) {
group.bench_function("arc_payload_1kb", |b| { group.bench_function("arc_payload_1kb", |b| {
b.iter(|| { b.iter(|| {
bus.emit( bus.emit(ArcPayloadEvent {
TOPIC,
ArcPayloadEvent {
id: 42, id: 42,
payload: Arc::clone(&shared_payload), payload: Arc::clone(&shared_payload),
}, });
);
}); });
}); });
@@ -112,13 +98,13 @@ fn bench_emit_no_subscriber(c: &mut Criterion) {
fn bench_raw_subscriber(c: &mut Criterion) { fn bench_raw_subscriber(c: &mut Criterion) {
let rt = runtime(); 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.throughput(Throughput::Elements(1));
group.bench_function("u64", |b| { group.bench_function("u64", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 mut rx = bus.on_raw(TOPIC); let mut rx = bus.subscribe::<u64>();
let received = Arc::new(AtomicU64::new(0)); let received = Arc::new(AtomicU64::new(0));
let receiver_count = Arc::clone(&received); let receiver_count = Arc::clone(&received);
@@ -134,23 +120,21 @@ fn bench_raw_subscriber(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit(TOPIC, i); bus.emit(i);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
receiver.abort(); receiver.abort();
elapsed elapsed
}); });
}); });
group.bench_function("small_struct", |b| { group.bench_function("small_struct", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 mut rx = bus.on_raw(TOPIC); let mut rx = bus.subscribe::<SmallEvent>();
let received = Arc::new(AtomicU64::new(0)); let received = Arc::new(AtomicU64::new(0));
let receiver_count = Arc::clone(&received); let receiver_count = Arc::clone(&received);
@@ -166,23 +150,21 @@ fn bench_raw_subscriber(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit(TOPIC, SmallEvent { value: i }); bus.emit(SmallEvent { value: i });
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
receiver.abort(); receiver.abort();
elapsed elapsed
}); });
}); });
group.bench_function("string_struct", |b| { group.bench_function("string_struct", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 mut rx = bus.on_raw(TOPIC); let mut rx = bus.subscribe::<StringEvent>();
let received = Arc::new(AtomicU64::new(0)); let received = Arc::new(AtomicU64::new(0));
let receiver_count = Arc::clone(&received); let receiver_count = Arc::clone(&received);
@@ -198,30 +180,25 @@ fn bench_raw_subscriber(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit( bus.emit(StringEvent {
TOPIC,
StringEvent {
id: i, id: i,
name: "Alice".to_string(), name: "Alice".to_string(),
message: "hello from benchmark".to_string(), message: "hello from benchmark".to_string(),
}, });
);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
receiver.abort(); receiver.abort();
elapsed elapsed
}); });
}); });
group.bench_function("vec_payload_1kb", |b| { group.bench_function("vec_payload_1kb", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 mut rx = bus.on_raw(TOPIC); let mut rx = bus.subscribe::<VecEvent>();
let received = Arc::new(AtomicU64::new(0)); let received = Arc::new(AtomicU64::new(0));
let receiver_count = Arc::clone(&received); let receiver_count = Arc::clone(&received);
@@ -237,31 +214,29 @@ fn bench_raw_subscriber(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit( bus.emit(VecEvent {
TOPIC,
VecEvent {
id: i, id: i,
payload: vec![7_u8; 1024], payload: vec![7_u8; 1024],
}, });
);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
receiver.abort(); receiver.abort();
elapsed elapsed
}); });
}); });
group.bench_function("arc_payload_1kb", |b| { let shared_payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice());
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 payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice()); group.bench_function("arc_payload_1kb", |b| {
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 received = Arc::new(AtomicU64::new(0)); let received = Arc::new(AtomicU64::new(0));
let receiver_count = Arc::clone(&received); let receiver_count = Arc::clone(&received);
@@ -277,22 +252,18 @@ fn bench_raw_subscriber(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit( bus.emit(ArcPayloadEvent {
TOPIC,
ArcPayloadEvent {
id: i, id: i,
payload: Arc::clone(&payload), payload: Arc::clone(&payload),
}, });
);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
receiver.abort(); receiver.abort();
elapsed elapsed
}
}); });
}); });
@@ -302,17 +273,17 @@ fn bench_raw_subscriber(c: &mut Criterion) {
fn bench_typed_callback(c: &mut Criterion) { fn bench_typed_callback(c: &mut Criterion) {
let rt = runtime(); 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.throughput(Throughput::Elements(1));
group.bench_function("u64", |b| { group.bench_function("u64", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 received = Arc::new(AtomicU64::new(0));
let handler_count = Arc::clone(&received); let handler_count = Arc::clone(&received);
let subscription = bus.on::<u64, _>(TOPIC, move |event| { let subscription = bus.on::<u64>(move |event| {
let _ = event; let _ = event;
handler_count.fetch_add(1, Ordering::Relaxed); handler_count.fetch_add(1, Ordering::Relaxed);
}); });
@@ -320,27 +291,25 @@ fn bench_typed_callback(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit(TOPIC, i); bus.emit(i);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
subscription.abort(); subscription.abort();
elapsed elapsed
}); });
}); });
group.bench_function("small_struct", |b| { group.bench_function("small_struct", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 received = Arc::new(AtomicU64::new(0));
let handler_count = Arc::clone(&received); let handler_count = Arc::clone(&received);
let subscription = bus.on::<SmallEvent, _>(TOPIC, move |event| { let subscription = bus.on::<SmallEvent>(move |event| {
let _ = event.value; let _ = event.value;
handler_count.fetch_add(1, Ordering::Relaxed); handler_count.fetch_add(1, Ordering::Relaxed);
}); });
@@ -348,27 +317,25 @@ fn bench_typed_callback(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit(TOPIC, SmallEvent { value: i }); bus.emit(SmallEvent { value: i });
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
subscription.abort(); subscription.abort();
elapsed elapsed
}); });
}); });
group.bench_function("string_struct", |b| { group.bench_function("string_struct", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 received = Arc::new(AtomicU64::new(0));
let handler_count = Arc::clone(&received); 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.id;
let _ = event.name.len(); let _ = event.name.len();
let _ = event.message.len(); let _ = event.message.len();
@@ -378,34 +345,29 @@ fn bench_typed_callback(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit( bus.emit(StringEvent {
TOPIC,
StringEvent {
id: i, id: i,
name: "Alice".to_string(), name: "Alice".to_string(),
message: "hello from benchmark".to_string(), message: "hello from benchmark".to_string(),
}, });
);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
subscription.abort(); subscription.abort();
elapsed elapsed
}); });
}); });
group.bench_function("vec_payload_1kb", |b| { group.bench_function("vec_payload_1kb", |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 received = Arc::new(AtomicU64::new(0));
let handler_count = Arc::clone(&received); 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.id;
let _ = event.payload.len(); let _ = event.payload.len();
handler_count.fetch_add(1, Ordering::Relaxed); handler_count.fetch_add(1, Ordering::Relaxed);
@@ -414,34 +376,33 @@ fn bench_typed_callback(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit( bus.emit(VecEvent {
TOPIC,
VecEvent {
id: i, id: i,
payload: vec![7_u8; 1024], payload: vec![7_u8; 1024],
}, });
);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
subscription.abort(); subscription.abort();
elapsed elapsed
}); });
}); });
let shared_payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice());
group.bench_function("arc_payload_1kb", |b| { group.bench_function("arc_payload_1kb", |b| {
b.to_async(&rt).iter_custom(|iters| async move { let shared = Arc::clone(&shared_payload);
let bus = Arc::new(EventBus::with_capacity(iters as usize + 1024)); b.to_async(&rt).iter_custom(|iters| {
let payload: Arc<[u8]> = Arc::from(vec![7_u8; 1024].into_boxed_slice()); let payload = Arc::clone(&shared);
async move {
let bus = EventBus::with_capacity(iters as usize + 1024);
let received = Arc::new(AtomicU64::new(0)); let received = Arc::new(AtomicU64::new(0));
let handler_count = Arc::clone(&received); let handler_count = Arc::clone(&received);
let subscription = bus.on::<ArcPayloadEvent, _>(TOPIC, move |event| { let subscription = bus.on::<ArcPayloadEvent>(move |event| {
let _ = event.id; let _ = event.id;
let _ = event.payload.len(); let _ = event.payload.len();
handler_count.fetch_add(1, Ordering::Relaxed); handler_count.fetch_add(1, Ordering::Relaxed);
@@ -450,22 +411,18 @@ fn bench_typed_callback(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit( bus.emit(ArcPayloadEvent {
TOPIC,
ArcPayloadEvent {
id: i, id: i,
payload: Arc::clone(&payload), payload: Arc::clone(&payload),
}, });
);
} }
wait_until_received(&received, iters).await; wait_until_received(&received, iters).await;
let elapsed = start.elapsed(); let elapsed = start.elapsed();
subscription.abort(); subscription.abort();
elapsed elapsed
}
}); });
}); });
@@ -475,13 +432,13 @@ fn bench_typed_callback(c: &mut Criterion) {
fn bench_multiple_subscribers(c: &mut Criterion) { fn bench_multiple_subscribers(c: &mut Criterion) {
let rt = runtime(); 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)); group.throughput(Throughput::Elements(1));
for subscriber_count in [1_u64, 2, 4, 8, 16, 32] { for subscriber_count in [1_u64, 2, 4, 8, 16, 32] {
group.bench_function(format!("{subscriber_count}_subscribers"), |b| { group.bench_function(format!("{subscriber_count}_subscribers"), |b| {
b.to_async(&rt).iter_custom(|iters| async move { 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 expected = iters * subscriber_count;
let received = Arc::new(AtomicU64::new(0)); let received = Arc::new(AtomicU64::new(0));
@@ -490,7 +447,7 @@ fn bench_multiple_subscribers(c: &mut Criterion) {
for _ in 0..subscriber_count { for _ in 0..subscriber_count {
let handler_count = Arc::clone(&received); let handler_count = Arc::clone(&received);
let subscription = bus.on::<SmallEvent, _>(TOPIC, move |event| { let subscription = bus.on::<SmallEvent>(move |event| {
let _ = event.value; let _ = event.value;
handler_count.fetch_add(1, Ordering::Relaxed); handler_count.fetch_add(1, Ordering::Relaxed);
}); });
@@ -501,7 +458,7 @@ fn bench_multiple_subscribers(c: &mut Criterion) {
let start = Instant::now(); let start = Instant::now();
for i in 0..iters { for i in 0..iters {
bus.emit(TOPIC, SmallEvent { value: i }); bus.emit(SmallEvent { value: i });
} }
wait_until_received(&received, expected).await; wait_until_received(&received, expected).await;
+410 -401
View File
@@ -1,420 +1,90 @@
use std::any::Any; use std::any::{Any, TypeId};
use std::future::Future; use std::collections::HashMap;
use std::fmt::Debug;
use std::sync::Arc; use std::sync::Arc;
use parking_lot::RwLock; use parking_lot::RwLock;
use std::collections::HashMap;
use std::iter;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
// use tracing::log::kv::{Key, Value};
use tracing::{debug, trace, warn}; use tracing::{debug, trace, warn};
use uuid::Uuid;
/// Raw event type: an atomic reference-counted pointer to any value. use crate::handler::{AsyncHandler, AsyncHandlerWith};
pub type AnyEvent = Arc<dyn Any + Send + Sync>;
/// Default buffer capacity for each broadcast channel. /// Default buffer capacity for each broadcast channel.
const DEFAULT_CAPACITY: usize = 64; pub const DEFAULT_CAPACITY: usize = 1024;
#[derive(Debug, Clone, PartialEq, Eq)] /// Marker trait for events dispatched through [`EventBus`].
pub enum ScopeValue { ///
String(String), /// Any type implementing `Clone + Send + Sync + 'static` automatically
Uuid(Uuid), /// implements `Event`.
} pub trait Event: Clone + Send + Sync + 'static {}
impl ScopeValue {
fn into_string(self) -> String {
match self {
Self::String(value) => value,
Self::Uuid(value) => value.to_string(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)] impl<T: Clone + Send + Sync + 'static> Event for T {}
pub struct Scope {
pub key: String,
pub value: ScopeValue,
}
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)] #[derive(Debug)]
pub struct EventBus { struct EventBusInner {
/// Channels indexed by exact topic. channels: RwLock<HashMap<TypeId, Box<dyn Any + Send + Sync>>>,
channels: RwLock<HashMap<String, broadcast::Sender<AnyEvent>>>,
capacity: usize, capacity: usize,
} }
impl EventBus { /// A central, strongly-typed broadcast event bus.
/// Creates a bus with the default capacity (64 messages per channel). ///
pub fn new() -> Self { /// Unlike string/topic-based event buses, [`EventBus`] dispatches events directly
debug!( /// based on the concrete Rust type of the event (using [`TypeId`]).
"EventBus created with default capacity ({})", ///
DEFAULT_CAPACITY /// 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.
Self { ///
channels: RwLock::new(HashMap::new()), /// # Sync callback example
capacity: DEFAULT_CAPACITY, /// ```rust,no_run
} /// use event_bus::EventBus;
} ///
/// #[derive(Clone, Debug)]
/// Creates a bus with a custom buffer capacity. /// struct MessageCreatedEvent {
pub fn with_capacity(capacity: usize) -> Self { /// content: String,
debug!("EventBus created with capacity {}", capacity); /// }
Self { ///
channels: RwLock::new(HashMap::new()), /// #[tokio::main]
capacity, /// async fn main() {
} /// let bus = EventBus::new();
} ///
/// bus.on::<MessageCreatedEvent>(|event| {
// ───────────────────────────────────────────────────────────────────────── /// println!("Created message: {:?}", event);
// Internal /// });
// ───────────────────────────────────────────────────────────────────────── ///
/// bus.emit(MessageCreatedEvent {
fn get_or_create_sender(&self, topic: &str) -> broadcast::Sender<AnyEvent> { /// content: "Hello!".into(),
{ /// });
let channels = self.channels.read(); /// }
if let Some(tx) = channels.get(topic) { /// ```
return tx.clone(); ///
} /// # Async callback example
} /// ```rust,no_run
let mut channels = self.channels.write(); /// use event_bus::EventBus;
let created = !channels.contains_key(topic); ///
let tx = channels /// #[derive(Clone, Debug)]
.entry(topic.to_string()) /// struct MessageUpdatedEvent {
.or_insert_with(|| { /// id: u64,
let (tx, _) = broadcast::channel(self.capacity); /// content: String,
tx /// }
}) ///
.clone(); /// #[tokio::main]
if created { /// async fn main() {
debug!(topic, "New broadcast channel created"); /// let bus = EventBus::new();
} ///
tx /// bus.on_async::<MessageUpdatedEvent>(|event| async move {
} /// println!("(async) Updated message: {:?}", event);
/// });
// ───────────────────────────────────────────────────────────────────────── ///
// Emission /// bus.emit(MessageUpdatedEvent {
// ───────────────────────────────────────────────────────────────────────── /// id: 42,
/// content: "World!".into(),
/// 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. #[derive(Debug, Clone)]
/// pub struct EventBus {
/// # Example inner: Arc<EventBusInner>,
/// ```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()
}
} }
impl Default for EventBus { impl Default for EventBus {
@@ -422,3 +92,342 @@ impl Default for EventBus {
Self::new() 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);
}
}
+47
View File
@@ -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
View File
@@ -1,49 +1,49 @@
/// Downcasts an [`AnyEvent`] to one or more concrete types and executes //! # event_bus
/// the matching closure if the type matches. //!
/// //! A strongly-typed, high-performance in-memory event bus for Tokio.
/// Non-matching branches are silently ignored. //!
/// //! ## Overview
/// # Syntax //!
/// ```text //! Unlike string/topic-based event buses, `event_bus` routes events using
/// match_event!(evt, Type1 => |val| { ... }, Type2 => |val| { ... }) //! their concrete Rust types ([`std::any::TypeId`]).
/// ``` //!
/// //! - **Strong typing**: No string keys required for event types, no manual `match_event!`
/// # Example //! macros, and no runtime downcasting (`downcast_ref`) inside the subscriber loops.
/// ```rust,no_run //! - **Ergonomic async subscribers**: Handlers can be registered with clean turbofish syntax:
/// # use std::sync::Arc; //! `bus.on_async::<MessageUpdatedEvent>(|event| async move { ... })`.
/// # use oxspeak_server_lib::event_bus::EventBus; //! - **Targeted wake-up**: Tokio broadcast channels are isolated per event type.
/// # use oxspeak_server_lib::match_event; //!
/// # #[derive(Clone, Debug)] struct User { name: String } //! ## Example
/// # #[derive(Clone, Debug)] struct UdpMetric { value: f32 } //!
/// # let bus = Arc::new(EventBus::new()); //! ```rust,no_run
/// # tokio_test::block_on(async { //! use event_bus::EventBus;
/// let mut rx = bus.on_raw("user-connected"); //!
/// bus.emit("user-connected", User { name: "Alice".into() }); //! #[derive(Clone, Debug, PartialEq)]
/// //! struct MessageCreatedEvent {
/// if let Ok(evt) = rx.recv().await { //! content: String,
/// match_event!(evt, //! }
/// User => |u| println!("User: {:?}", u), //!
/// UdpMetric => |m| println!("Metric: {:?}", m), //! #[tokio::main]
/// ); //! async fn main() {
/// } //! let bus = EventBus::new();
/// # }); //!
/// ``` //! // Async subscriber
#[macro_export] //! bus.on_async::<MessageCreatedEvent>(|event| async move {
macro_rules! match_event { //! println!("Received message: {}", event.content);
($evt:expr, $($type:ty => $handler:expr),+ $(,)?) => { //! });
$( //!
if let Some(val) = ($evt).downcast_ref::<$type>() { //! // Emit event
($handler)(val.clone()); //! bus.emit(MessageCreatedEvent {
} else //! content: "Hello from typed event bus!".into(),
)+ //! });
{ //! }
// No matching type → silently ignored //! ```
}
};
}
mod bus; 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)] #[cfg(test)]
mod tests; mod tests;
+312 -140
View File
@@ -1,192 +1,364 @@
use crate::{EventBus, match_event};
use std::sync::Arc; use std::sync::Arc;
use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
use uuid::Uuid;
use crate::EventBus;
#[derive(Clone, Debug, PartialEq)] #[derive(Clone, Debug, PartialEq)]
struct User { struct MessageCreatedEvent {
name: String, channel_id: Uuid,
content: String,
} }
#[derive(Clone, Debug, PartialEq)] #[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, value: f32,
} }
// ── on (callback sync) ──────────────────────────────────────────────────── // ── Sync Callbacks ──────────────────────────────────────────────────────────
#[tokio::test] #[tokio::test]
async fn test_on_callback_sync() { async fn test_on_callback_sync() {
let bus = Arc::new(EventBus::new()); let bus = EventBus::new();
let received = Arc::new(AtomicBool::new(false)); let received = Arc::new(AtomicBool::new(false));
let flag = Arc::clone(&received); let flag = Arc::clone(&received);
bus.on::<User, _>("user-connected", move |user| { bus.on::<MessageCreatedEvent>(move |event| {
if user.name == "Alice" { if event.content == "Hello" {
flag.store(true, Ordering::SeqCst); flag.store(true, Ordering::SeqCst);
} }
}); });
bus.emit( bus.emit(MessageCreatedEvent {
"user-connected", channel_id: Uuid::new_v4(),
User { content: "Hello".into(),
name: "Alice".into(), });
},
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
assert!(received.load(Ordering::SeqCst)); assert!(received.load(Ordering::SeqCst));
} }
#[tokio::test] // ── Async Callbacks (Exact User Requirement) ────────────────────────────────
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));
}
#[tokio::test] #[tokio::test]
async fn test_on_type_mismatch_ignored() { async fn test_on_async_callback_turbofish() {
// Émettre un UdpMetric sur un topic écouté en User → handler pas appelé let bus = EventBus::new();
let bus = Arc::new(EventBus::new()); let received_content = Arc::new(tokio::sync::Mutex::new(String::new()));
let called = Arc::new(AtomicBool::new(false)); let rc = Arc::clone(&received_content);
let flag = Arc::clone(&called);
bus.on::<User, _>("mixed-topic", move |_| { // Exact syntax specified by the user:
flag.store(true, Ordering::SeqCst); // event_bus.on_async::<MessageUpdatedEvent>(|event| async move { ... });
}); bus.on_async::<MessageUpdatedEvent>(move |event| {
let rc = Arc::clone(&rc);
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);
async move { 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); f.store(true, Ordering::SeqCst);
} }
} }
}); });
bus.emit( bus.emit(MessageUpdatedEvent {
"user-connected", id: 99,
User { content: "Inferred".into(),
name: "Async".into(), });
},
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
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] #[tokio::test]
async fn test_on_raw_and_match_event_macro() { async fn test_on_targeted_wakeup() {
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() {
let bus = EventBus::new(); let bus = EventBus::new();
// on_raw enregistre le canal (get_or_create) let metric_called = Arc::new(AtomicBool::new(false));
let _rx1 = bus.on_raw("user-connected"); let flag = Arc::clone(&metric_called);
let _rx2 = bus.on_raw("udp-metrics-updated");
let mut topics = bus.topics(); bus.on::<UdpMetricEvent>(move |_| {
topics.sort(); flag.store(true, Ordering::SeqCst);
assert_eq!(topics, vec!["udp-metrics-updated", "user-connected"]); });
// 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] #[tokio::test]
async fn test_emit_multiple_types_same_bus() { async fn test_multiple_subscribers_same_type() {
let bus = Arc::new(EventBus::new()); let bus = EventBus::new();
let user_ok = Arc::new(AtomicBool::new(false)); let count = Arc::new(AtomicU32::new(0));
let metric_ok = Arc::new(AtomicBool::new(false));
let u = Arc::clone(&user_ok);
let m = Arc::clone(&metric_ok);
bus.on::<User, _>("user-connected", move |user| { for _ in 0..3 {
if user.name == "Bob" { let c = Arc::clone(&count);
u.store(true, Ordering::SeqCst); bus.on::<MessageCreatedEvent>(move |_| {
} c.fetch_add(1, Ordering::SeqCst);
}); });
bus.on::<UdpMetric, _>("udp-metrics-updated", move |metric| {
if (metric.value - 3.14).abs() < 0.001 {
m.store(true, 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; 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)); 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");
}
+1
View File
@@ -36,6 +36,7 @@
"eslint-config-vuetify": "^4.3.4", "eslint-config-vuetify": "^4.3.4",
"npm-run-all2": "^8.0.4", "npm-run-all2": "^8.0.4",
"sass-embedded": "^1.98.0", "sass-embedded": "^1.98.0",
"smol-toml": "^1.9.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"unplugin-fonts": "^1.4.0", "unplugin-fonts": "^1.4.0",
"vite": "^8.0.0", "vite": "^8.0.0",
+21
View File
@@ -7,8 +7,10 @@ import ContextMenu from "@/components/ContextMenu.vue";
import UserListDrawer from '@/components/UserListDrawer.vue' import UserListDrawer from '@/components/UserListDrawer.vue'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue' import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue'
import {useContextMenu} from '@/composables/useContextMenu' import {useContextMenu} from '@/composables/useContextMenu'
import {useVoiceStore} from '@/stores/voice'
const serverStore = useServerStore() const serverStore = useServerStore()
const voiceStore = useVoiceStore()
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const {openContextMenu} = useContextMenu() const {openContextMenu} = useContextMenu()
@@ -212,6 +214,14 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
/> />
<router-view/> <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 --> <!-- Menu contextuel global -->
<ContextMenu/> <ContextMenu/>
<v-dialog v-model="showDialog" width="400"> <v-dialog v-model="showDialog" width="400">
@@ -284,6 +294,17 @@ function onServerContextMenu(event: MouseEvent, server: Server) {
</template> </template>
<style scoped> <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 { .space-y-4 {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+1 -1
View File
@@ -60,7 +60,7 @@ async function handleRegister() {
throw new Error(errData.message || 'Échec de l\'inscription') 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) { } catch (err) {
error.value = err instanceof Error ? err.message : 'Une erreur est survenue' error.value = err instanceof Error ? err.message : 'Une erreur est survenue'
} finally { } finally {
+361
View File
@@ -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>
+60 -6
View File
@@ -14,7 +14,8 @@ import type {Channel} from '@/stores/channel'
import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue' import ChannelPermissionsDialog from '@/components/permissions/ChannelPermissionsDialog.vue'
import {useAuthStore} from '@/stores/auth' import {useAuthStore} from '@/stores/auth'
import ServerSettingsDialog from '@/components/server/ServerSettingsDialog.vue' 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<{ const props = defineProps<{
serverId: string serverId: string
@@ -30,6 +31,11 @@ const serverStore = useServerStore()
const {currentTree} = storeToRefs(serverStore) const {currentTree} = storeToRefs(serverStore)
const {openContextMenu} = useContextMenu() const {openContextMenu} = useContextMenu()
const authStore = useAuthStore() 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 showPermissionsDialog = ref(false)
const selectedChannel = ref<any | null>(null) const selectedChannel = ref<any | null>(null)
const channelToEdit = ref<Channel | null>(null) const channelToEdit = ref<Channel | null>(null)
@@ -37,6 +43,19 @@ const showEditChannelDialog = ref(false)
const showServerSettings = ref(false) const showServerSettings = ref(false)
const serverName = computed(() => serverStore.servers.find(server => server.id === props.serverId)?.name || 'Serveur') 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) => { const loadServerData = async (targetServerId: string) => {
if (!targetServerId) return if (!targetServerId) return
@@ -55,13 +74,27 @@ const loadServerData = async (targetServerId: string) => {
} }
let stopReloadAll: (() => void) | null = null let stopReloadAll: (() => void) | null = null
let stopVoicePresence: (() => void) | null = null
onMounted(() => { onMounted(() => {
stopReloadAll = onReloadAll(() => loadServerData(props.serverId)) 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(() => { onUnmounted(() => {
stopReloadAll?.() stopReloadAll?.()
stopVoicePresence?.()
}) })
watch( watch(
@@ -286,17 +319,17 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
/> />
</template> </template>
<template v-for="channel in item.Category[1]" :key="channel.id">
<v-list-item <v-list-item
v-for="channel in item.Category[1]"
:key="channel.id"
:title="channel.name" :title="channel.name"
:to="`/server/${serverId}/channel/${channel.id}`" :active="voiceStore.channelId === channel.id"
:class="{ :class="{
'font-weight-bold': (channel.unread_count ?? 0) > 0, 'font-weight-bold': (channel.unread_count ?? 0) > 0,
'server-item-drop-before': isDropTarget(channel.id, 'before'), 'server-item-drop-before': isDropTarget(channel.id, 'before'),
'server-item-drop-after': isDropTarget(channel.id, 'after'), 'server-item-drop-after': isDropTarget(channel.id, 'after'),
}" }"
link link
@click="selectChannel(channel)"
draggable="true" draggable="true"
@dragstart="startDragging($event, channel.id, 'channel')" @dragstart="startDragging($event, channel.id, 'channel')"
@dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)" @dragover="dragOver($event, channel.id, 'channel', item.Category[0].id)"
@@ -304,6 +337,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
@dragend="stopDragging" @dragend="stopDragging"
@contextmenu="onChannelContextMenu($event, channel)" @contextmenu="onChannelContextMenu($event, channel)"
> >
<template #prepend><v-icon :icon="channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
<template #append> <template #append>
<v-chip <v-chip
v-if="(channel.unread_count ?? 0) > 0" v-if="(channel.unread_count ?? 0) > 0"
@@ -316,20 +350,27 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
</v-chip> </v-chip>
</template> </template>
</v-list-item> </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> </v-list-group>
<!-- Canal orphelin (racine) --> <!-- Canal orphelin (racine) -->
<template v-else-if="'Channel' in item">
<v-list-item <v-list-item
v-else-if="'Channel' in item"
:key="item.Channel.id" :key="item.Channel.id"
:title="item.Channel.name" :title="item.Channel.name"
:to="`/server/${serverId}/channel/${item.Channel.id}`" :active="voiceStore.channelId === item.Channel.id"
:class="{ :class="{
'font-weight-bold': (item.Channel.unread_count ?? 0) > 0, 'font-weight-bold': (item.Channel.unread_count ?? 0) > 0,
'server-item-drop-before': isDropTarget(item.Channel.id, 'before'), 'server-item-drop-before': isDropTarget(item.Channel.id, 'before'),
'server-item-drop-after': isDropTarget(item.Channel.id, 'after'), 'server-item-drop-after': isDropTarget(item.Channel.id, 'after'),
}" }"
link link
@click="selectChannel(item.Channel)"
draggable="true" draggable="true"
@dragstart="startDragging($event, item.Channel.id, 'channel')" @dragstart="startDragging($event, item.Channel.id, 'channel')"
@dragover="dragOver($event, item.Channel.id, 'channel', null)" @dragover="dragOver($event, item.Channel.id, 'channel', null)"
@@ -337,6 +378,7 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
@dragend="stopDragging" @dragend="stopDragging"
@contextmenu="onChannelContextMenu($event, item.Channel)" @contextmenu="onChannelContextMenu($event, item.Channel)"
> >
<template #prepend><v-icon :icon="item.Channel.channel_type === 'voice' ? 'mdi-volume-high' : 'mdi-pound'" /></template>
<template #append> <template #append>
<v-chip <v-chip
v-if="(item.Channel.unread_count ?? 0) > 0" v-if="(item.Channel.unread_count ?? 0) > 0"
@@ -349,9 +391,16 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
</v-chip> </v-chip>
</template> </template>
</v-list-item> </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> </template>
</v-list> </v-list>
</v-navigation-drawer> </v-navigation-drawer>
<CreateChannelDialog <CreateChannelDialog
@@ -391,6 +440,11 @@ function onChannelContextMenu(event: MouseEvent, channel: any) {
</template> </template>
<style scoped> <style scoped>
.voice-participants {
padding-left: 48px;
color: rgb(var(--v-theme-on-surface));
opacity: 0.75;
}
.server-item-drop-before { .server-item-drop-before {
border-top: 2px solid rgb(var(--v-theme-primary)); border-top: 2px solid rgb(var(--v-theme-primary));
} }
+5
View File
@@ -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})', path: 'server/:serverId(default|[0-9a-fA-F-]{36})',
name: 'server-dashboard', name: 'server-dashboard',
+1 -1
View File
@@ -2,6 +2,6 @@ import {defineStore} from 'pinia'
export const useAppStore = defineStore('app', { export const useAppStore = defineStore('app', {
state: () => ({ state: () => ({
baseurl: 'http://localhost:8080', baseurl: '',
}), }),
}); });
+2
View File
@@ -1,5 +1,6 @@
import {defineStore} from 'pinia' import {defineStore} from 'pinia'
import {useApi} from "@/composables/useApi"; import {useApi} from "@/composables/useApi";
import {useVoiceStore} from '@/stores/voice'
export interface User { export interface User {
id: string id: string
@@ -55,6 +56,7 @@ export const useAuthStore = defineStore('auth', {
}, },
async logout() { async logout() {
useVoiceStore().leave()
const api = useApi() const api = useApi()
try { try {
await api.post('/auth/logout') await api.post('/auth/logout')
+1
View File
@@ -5,6 +5,7 @@ export interface Channel {
id: string id: string
name?: string name?: string
channel_type: string channel_type: string
voice_participants?: {id: string; username: string}[]
server_id?: string | null server_id?: string | null
category_id?: string | null category_id?: string | null
created_at: string created_at: string
+125
View File
@@ -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)
}
+23 -1
View File
@@ -1,8 +1,21 @@
import {readFileSync} from 'node:fs'
import {resolve} from 'node:path'
import {fileURLToPath, URL} from 'node:url' import {fileURLToPath, URL} from 'node:url'
import Vue from '@vitejs/plugin-vue' import Vue from '@vitejs/plugin-vue'
import Fonts from 'unplugin-fonts/vite' import Fonts from 'unplugin-fonts/vite'
import {defineConfig} from 'vite' import {defineConfig} from 'vite'
import Vuetify, {transformAssetUrls} from 'vite-plugin-vuetify' 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/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
@@ -46,10 +59,19 @@ export default defineConfig({
}, },
server: { server: {
port: 3000, port: 3000,
host: '0.0.0.0',
https,
allowedHosts: ["goesseau.eu"],
proxy: { proxy: {
'/api': { '/api': {
target: 'http://localhost:8080', target,
changeOrigin: true, changeOrigin: true,
secure: !tls,
},
'/ws': {
target,
ws: true,
secure: !tls,
}, },
}, },
}, },
+5
View File
@@ -2519,6 +2519,11 @@ sisteransi@^1.0.5:
resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz" resolved "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz"
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg== 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: "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" version "1.2.1"
resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz"
+1 -1
View File
@@ -13,7 +13,7 @@ async-std = { version = "1", features = ["attributes", "tokio1"] }
uuid = { version = "1", features = ["v5"] } uuid = { version = "1", features = ["v5"] }
[dependencies.sea-orm-migration] [dependencies.sea-orm-migration]
version = "2.0.2" version = "2.0.3"
features = [ features = [
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI. # Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime. # View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
+111
View File
@@ -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.
+62
View File
@@ -50,6 +50,16 @@ host = "0.0.0.0"
tcp_port = 8080 tcp_port = 8080
# WebRTC ICE/Media UDP multiplexing port # WebRTC ICE/Media UDP multiplexing port
udp_port = 8080 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] [database]
# DSN for database # DSN for database
@@ -103,6 +113,23 @@ pub struct NetworkConfig {
pub hostv6: Option<Ipv6Addr>, pub hostv6: Option<Ipv6Addr>,
pub tcp_port: u16, pub tcp_port: u16,
pub udp_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)] #[derive(Clone, Deserialize)]
@@ -155,3 +182,38 @@ impl AppConfig {
Self::load() 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);
}
}
}
+12
View File
@@ -6,6 +6,7 @@ use crate::http::server::HttpServer;
use crate::metrics::{AppMetrics, reporter}; use crate::metrics::{AppMetrics, reporter};
use crate::repositories::Repositories; use crate::repositories::Repositories;
use crate::routes::gateway::{GatewayManager, RealtimeRouter}; use crate::routes::gateway::{GatewayManager, RealtimeRouter};
use crate::rtc::RTCManager;
use crate::services::Services; use crate::services::Services;
use crate::voice::VoiceService; use crate::voice::VoiceService;
use event_bus::EventBus; use event_bus::EventBus;
@@ -82,6 +83,16 @@ impl App {
)) ))
.start(event_bus.clone()); .start(event_bus.clone());
let rtc = Arc::new(
RTCManager::new(
&config.network,
repositories.clone(),
services.clone(),
event_bus.clone(),
)
.await,
);
let state = AppState { let state = AppState {
db, db,
config: Arc::new(config), config: Arc::new(config),
@@ -93,6 +104,7 @@ impl App {
event_bus, event_bus,
services, services,
voice, voice,
rtc,
}; };
Ok(Self { state }) Ok(Self { state })
+2
View File
@@ -3,6 +3,7 @@ use crate::metrics::AppMetrics;
use crate::models::server; use crate::models::server;
use crate::repositories::Repositories; use crate::repositories::Repositories;
use crate::routes::gateway::GatewayManager; use crate::routes::gateway::GatewayManager;
use crate::rtc::RTCManager;
use crate::services::Services; use crate::services::Services;
use crate::voice::VoiceService; use crate::voice::VoiceService;
use event_bus::EventBus; use event_bus::EventBus;
@@ -20,6 +21,7 @@ pub struct AppState {
pub gateway: Arc<GatewayManager>, pub gateway: Arc<GatewayManager>,
pub event_bus: Arc<EventBus>, pub event_bus: Arc<EventBus>,
pub services: Arc<Services>, pub services: Arc<Services>,
pub rtc: Arc<RTCManager>,
pub voice: Arc<VoiceService>, pub voice: Arc<VoiceService>,
} }
+3
View File
@@ -1,4 +1,5 @@
use crate::models::channel::ChannelType; use crate::models::channel::ChannelType;
use crate::rtc::VoiceParticipant;
use chrono::{DateTime, Utc}; use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema; use utoipa::ToSchema;
@@ -49,6 +50,8 @@ pub struct ChannelResponse {
/// Some(value) : valeur de computed_permission (0 si absente). /// Some(value) : valeur de computed_permission (0 si absente).
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub permission: Option<u64>, pub permission: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub voice_participants: Option<Vec<VoiceParticipant>>,
} }
#[derive(Debug, Serialize, Deserialize, ToSchema)] #[derive(Debug, Serialize, Deserialize, ToSchema)]
+16
View File
@@ -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,
}
+27
View File
@@ -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>,
}
+6
View File
@@ -1,5 +1,11 @@
pub mod category;
pub mod channel; pub mod channel;
pub mod channel_permission;
pub mod emoji; pub mod emoji;
pub mod message; pub mod message;
pub mod role;
pub mod server; pub mod server;
pub mod server_permission;
pub mod server_tree; pub mod server_tree;
pub mod user;
pub mod voice_presence;
+29
View File
@@ -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,
}
+13
View File
@@ -1,4 +1,6 @@
use crate::models::server; use crate::models::server;
use crate::models::server_user;
use uuid::Uuid;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ServerCreatedEvent { pub struct ServerCreatedEvent {
@@ -13,4 +15,15 @@ pub struct ServerUpdatedEvent {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct ServerDeletedEvent { pub struct ServerDeletedEvent {
pub server: server::Model, 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,
} }
+16
View File
@@ -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>,
}
+21
View File
@@ -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,
}
+12
View File
@@ -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
View File
@@ -23,9 +23,10 @@ pub struct RequestContext {
/// ///
/// **Usage :** /// **Usage :**
/// ```rust /// ```rust
/// pub async fn ma_vue(user: CurrentUser) { /// use oxspeak_server_lib::http::context::CurrentUser;
/// if user.is_superuser { ... } /// # fn check(user: CurrentUser) {
/// } /// # let _ = user.is_superuser;
/// # }
/// ``` /// ```
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct CurrentUser(pub user::Model); pub struct CurrentUser(pub user::Model);
@@ -71,6 +72,7 @@ where
/// ///
/// **Usage :** /// **Usage :**
/// ```rust /// ```rust
/// use oxspeak_server_lib::http::context::Superuser;
/// pub async fn suppression_globale(admin: Superuser) { /// pub async fn suppression_globale(admin: Superuser) {
/// // Ici, nous sommes certains que admin.is_superuser est true. /// // Ici, nous sommes certains que admin.is_superuser est true.
/// } /// }
+1 -3
View File
@@ -79,9 +79,7 @@ impl IntoResponse for HTTPError {
.into_response(); .into_response();
} }
HTTPError::Internal(err) => { HTTPError::Internal(err) => {
// On utilise %err pour un message d'erreur clair sans backtrace brute tracing::error!(error = %format_args!("{err:#}"), "Request error");
// mais on garde les détails pour le span tracing si besoin.
tracing::error!(%err, "Request error");
(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error") (StatusCode::INTERNAL_SERVER_ERROR, "Internal server error")
} }
}; };
+3
View File
@@ -7,6 +7,9 @@ pub mod metrics;
pub mod middleware; pub mod middleware;
pub mod permissions; pub mod permissions;
pub mod server; pub mod server;
mod tls;
#[cfg(test)]
pub(crate) mod test_support;
pub mod validation; pub mod validation;
pub use permissions::{RequireChannelPermission, RequireServerPermission}; pub use permissions::{RequireChannelPermission, RequireServerPermission};
+81 -88
View File
@@ -1,11 +1,11 @@
// Unused
use super::context::CurrentUser; use super::context::CurrentUser;
use super::error::HTTPError; use super::error::HTTPError;
use crate::core::AppState; use crate::core::AppState;
use crate::permissions::{ChannelPermission, ServerPermission}; use crate::permissions::{ChannelPermission, ServerPermission};
use axum::extract::FromRequestParts; use axum::extract::{FromRequestParts, RawPathParams};
use axum::http::request::Parts; 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 std::ops::Deref;
use uuid::Uuid; use uuid::Uuid;
@@ -15,23 +15,21 @@ use uuid::Uuid;
/// The target `server_id` is automatically extracted from path parameters (supporting /// The target `server_id` is automatically extracted from path parameters (supporting
/// path parameters named `server_id` or `id`). /// 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 /// # Usage Example
/// ```rust /// ```rust
/// use axum::extract::State; /// use axum::extract::{Path, State};
/// use uuid::Uuid; /// use uuid::Uuid;
/// use crate::http::permissions::RequireServerPermission; /// use oxspeak_server_lib::http::permissions::RequireServerPermission;
/// use crate::permissions::ServerPermission; /// use oxspeak_server_lib::http::error::HTTPError;
/// use crate::core::AppState; /// use oxspeak_server_lib::permissions::ServerPermission;
/// use oxspeak_server_lib::core::AppState;
/// ///
/// pub async fn update_server_settings( /// 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>, /// State(state): State<AppState>,
/// Path(server_id): Path<Uuid>, /// Path(_server_id): Path<Uuid>,
/// ) -> Result<(), HTTPError> { /// ) -> Result<(), HTTPError> {
/// // User has MANAGE_SERVER or is a superuser /// // User has MANAGE_SERVER
/// Ok(()) /// Ok(())
/// } /// }
/// ``` /// ```
@@ -46,30 +44,15 @@ impl<const PERM: u64> Deref for RequireServerPermission<PERM> {
} }
} }
impl<S, const PERM: u64> FromRequestParts<S> for RequireServerPermission<PERM> impl<const PERM: u64> FromRequestParts<AppState> for RequireServerPermission<PERM> {
where
S: Send + Sync,
{
type Rejection = HTTPError; 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) // 1. Extract CurrentUser (which validates authentication and returns 401 if missing)
let current_user = CurrentUser::from_request_parts(parts, state).await?; 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 // 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. // 4. Extract server_id from path parameters.
let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) { let server_id = match extract_path_param_uuid(parts, &["server_id", "id"]) {
@@ -82,22 +65,7 @@ where
}; };
// 5. Check user permission via server repository // 5. Check user permission via server repository
let permission_result = app_state if check_server_permission(state, current_user.id, server_id, ServerPermission::from_bits_truncate(PERM)).await? {
.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) {
Ok(RequireServerPermission(current_user)) Ok(RequireServerPermission(current_user))
} else { } else {
Err(HTTPError::Forbidden) Err(HTTPError::Forbidden)
@@ -110,23 +78,21 @@ where
/// ///
/// The target `channel_id` (or `id`) is automatically extracted from path parameters. /// 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 /// # Usage Example
/// ```rust /// ```rust
/// use axum::extract::State; /// use axum::extract::{Path, State};
/// use uuid::Uuid; /// use uuid::Uuid;
/// use crate::http::permissions::RequireChannelPermission; /// use oxspeak_server_lib::http::permissions::RequireChannelPermission;
/// use crate::permissions::ChannelPermission; /// use oxspeak_server_lib::http::error::HTTPError;
/// use crate::core::AppState; /// use oxspeak_server_lib::permissions::ChannelPermission;
/// use oxspeak_server_lib::core::AppState;
/// ///
/// pub async fn read_channel_messages( /// 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>, /// State(state): State<AppState>,
/// Path(channel_id): Path<Uuid>, /// Path(_channel_id): Path<Uuid>,
/// ) -> Result<(), HTTPError> { /// ) -> Result<(), HTTPError> {
/// // User has READ_CHANNEL or is a superuser /// // User has READ_CHANNEL
/// Ok(()) /// Ok(())
/// } /// }
/// ``` /// ```
@@ -141,27 +107,13 @@ impl<const PERM: u64> Deref for RequireChannelPermission<PERM> {
} }
} }
impl<S, const PERM: u64> FromRequestParts<S> for RequireChannelPermission<PERM> impl<const PERM: u64> FromRequestParts<AppState> for RequireChannelPermission<PERM> {
where
S: Send + Sync,
{
type Rejection = HTTPError; 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?; 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"]) { let channel_id = match extract_path_param_uuid(parts, &["channel_id", "id"]) {
Some(id) => id, Some(id) => id,
@@ -172,22 +124,7 @@ where
} }
}; };
let permission_result = app_state if check_channel_permission(state, current_user.id, channel_id, ChannelPermission::from_bits_truncate(PERM)).await? {
.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) {
Ok(RequireChannelPermission(current_user)) Ok(RequireChannelPermission(current_user))
} else { } else {
Err(HTTPError::Forbidden) Err(HTTPError::Forbidden)
@@ -198,6 +135,13 @@ where
/// Helper function to extract a Uuid path parameter matching any of the given key names /// Helper function to extract a Uuid path parameter matching any of the given key names
/// from Axum request extensions. /// from Axum request extensions.
fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> { 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 if let Some(map) = parts
.extensions .extensions
.get::<std::collections::HashMap<String, String>>() .get::<std::collections::HashMap<String, String>>()
@@ -223,3 +167,52 @@ fn extract_path_param_uuid(parts: &Parts, keys: &[&str]) -> Option<Uuid> {
None 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
View File
@@ -15,12 +15,13 @@ use tower_http::catch_panic::CatchPanicLayer;
use tower_http::cors::CorsLayer; use tower_http::cors::CorsLayer;
use tower_http::trace::TraceLayer; use tower_http::trace::TraceLayer;
use crate::config::NetworkConfig; use crate::config::{NetworkConfig, TlsConfig};
use crate::core::AppState; use crate::core::AppState;
use crate::routes; use crate::routes;
use super::metrics::HttpMetrics; use super::metrics::HttpMetrics;
use super::middleware; use super::middleware;
use super::tls;
// ── Erreurs ─────────────────────────────────────────────────────────────────── // ── Erreurs ───────────────────────────────────────────────────────────────────
@@ -66,6 +67,7 @@ pub enum HttpServerError {
/// ``` /// ```
pub struct HttpServer { pub struct HttpServer {
bind_addr: SocketAddr, bind_addr: SocketAddr,
tls: Option<TlsConfig>,
app_state: AppState, app_state: AppState,
metrics: Arc<HttpMetrics>, metrics: Arc<HttpMetrics>,
shutdown_rx: broadcast::Receiver<()>, shutdown_rx: broadcast::Receiver<()>,
@@ -87,6 +89,7 @@ impl HttpServer {
( (
Self { Self {
bind_addr, bind_addr,
tls: network_config.tls.clone(),
app_state, app_state,
metrics, metrics,
shutdown_rx, shutdown_rx,
@@ -149,6 +152,22 @@ impl HttpServer {
// Outermost : intercepte les panics et retourne une 500 propre // Outermost : intercepte les panics et retourne une 500 propre
.layer(CatchPanicLayer::new()); .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 = let listener =
TcpListener::bind(self.bind_addr) TcpListener::bind(self.bind_addr)
.await .await
+48
View File
@@ -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
}
+63
View File
@@ -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();
}
}
+2
View File
@@ -16,3 +16,5 @@ pub mod domain;
pub mod services; pub mod services;
pub mod utils; pub mod utils;
pub mod rtc;
+27 -7
View File
@@ -3,8 +3,9 @@ use crate::domain::dto::attachment::AttachmentUploadResponse;
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::models::attachment; use crate::models::attachment;
use crate::permissions::ChannelPermission;
use crate::routes::attachment::mapper; 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 crate::services::media::{self, PendingMediaFile};
use axum::body::Body; use axum::body::Body;
use axum::extract::{Multipart, Path, State}; use axum::extract::{Multipart, Path, State};
@@ -75,9 +76,14 @@ pub async fn create(
.content_type() .content_type()
.unwrap_or("application/octet-stream") .unwrap_or("application/octet-stream")
.to_string(); .to_string();
if !can_access(&state, channel, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} channel,
user.id,
user.is_superuser,
ChannelPermission::ATTACH_FILES,
)
.await?;
let id = Uuid::new_v4(); let id = Uuid::new_v4();
let mut output = PendingMediaFile::begin( let mut output = PendingMediaFile::begin(
PathBuf::from(&state.config.media.root).as_path(), PathBuf::from(&state.config.media.root).as_path(),
@@ -128,9 +134,14 @@ pub async fn create(
} }
let channel_id = let channel_id =
channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?; channel_id.ok_or_else(|| HTTPError::BadRequest("channel_id is required".into()))?;
if !can_access(&state, channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} channel_id,
user.id,
user.is_superuser,
ChannelPermission::ATTACH_FILES,
)
.await?;
if created.is_empty() { if created.is_empty() {
return Err(HTTPError::BadRequest( return Err(HTTPError::BadRequest(
"at least one file is required".into(), "at least one file is required".into(),
@@ -145,6 +156,7 @@ pub async fn create(
} }
pub async fn file( pub async fn file(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Response, HTTPError> { ) -> Result<Response, HTTPError> {
@@ -152,6 +164,14 @@ pub async fn file(
.one(&state.db) .one(&state.db)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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)) let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(&item.file_path))
.await .await
.map_err(|_| HTTPError::NotFound)?; .map_err(|_| HTTPError::NotFound)?;
+94
View File
@@ -15,3 +15,97 @@ pub fn secure_router() -> Router<AppState> {
pub fn public_router() -> Router<AppState> { pub fn public_router() -> Router<AppState> {
Router::new().route("/attachments/{id}/file", get(handlers::file)) 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();
}
}
+22
View File
@@ -9,3 +9,25 @@ pub fn router() -> OxRouter {
.route("/auth/bearer-login", post(handlers::login_bearer)) .route("/auth/bearer-login", post(handlers::login_bearer))
.route("/auth/me", get(handlers::me)) .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);
}
}
+16 -5
View File
@@ -2,8 +2,10 @@ use crate::core::state::AppState;
use crate::domain::dto::category::{ use crate::domain::dto::category::{
CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest, CategoryQueryParams, CategoryResponse, CreateCategoryRequest, UpdateCategoryRequest,
}; };
use crate::http::context::Superuser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission;
use crate::routes::server::handlers::require_server_permission;
use crate::routes::category::mapper; use crate::routes::category::mapper;
use axum::{ use axum::{
Json, Json,
@@ -26,9 +28,12 @@ use uuid::Uuid;
tag = "Categories" tag = "Categories"
)] )]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<CategoryQueryParams>, Query(filters): Query<CategoryQueryParams>,
) -> Result<Json<Vec<CategoryResponse>>, HTTPError> { ) -> 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 let categories = state
.repositories .repositories
.category .category
@@ -57,6 +62,7 @@ pub async fn get_all(
tag = "Categories" tag = "Categories"
)] )]
pub async fn get_by_id( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<CategoryResponse>, HTTPError> { ) -> Result<Json<CategoryResponse>, HTTPError> {
@@ -66,6 +72,7 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::category_model_to_category_response(category)))
} }
@@ -86,7 +93,7 @@ pub async fn get_by_id(
) )
)] )]
pub async fn create( pub async fn create(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreateCategoryRequest>, Json(payload): Json<CreateCategoryRequest>,
) -> Result<(StatusCode, Json<CategoryResponse>), HTTPError> { ) -> Result<(StatusCode, Json<CategoryResponse>), HTTPError> {
@@ -97,6 +104,7 @@ pub async fn create(
.get_by_id(payload.server_id) .get_by_id(payload.server_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?; .ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
require_server_permission(&state, &user, payload.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
let category = state let category = state
.services .services
@@ -128,18 +136,19 @@ pub async fn create(
) )
)] )]
pub async fn update( pub async fn update(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateCategoryRequest>, Json(payload): Json<UpdateCategoryRequest>,
) -> Result<Json<CategoryResponse>, HTTPError> { ) -> Result<Json<CategoryResponse>, HTTPError> {
// Vérifier l'existence // Vérifier l'existence
let _category = state let category = state
.repositories .repositories
.category .category
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_server_permission(&state, &user, category.server_id, ServerPermission::MANAGE_CATEGORIES).await?;
let category = state let category = state
.services .services
@@ -168,10 +177,12 @@ pub async fn update(
) )
)] )]
pub async fn delete( pub async fn delete(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> 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? { if state.services.category.delete_category(id).await? {
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
+3
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
mod tests;
+88
View File
@@ -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
);
}
+70 -13
View File
@@ -4,8 +4,11 @@ use crate::domain::dto::channel::{
ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse, ChannelUserPermissionResponse, CreateChannelRequest, ReadStateResponse,
SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest, SetChannelPermissionRequest, SetReadStateRequest, UpdateChannelRequest,
}; };
use crate::http::context::{CurrentUser, Superuser}; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; 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::models::{channel, channel_user};
use crate::routes::channel::mapper; use crate::routes::channel::mapper;
use axum::{ use axum::{
@@ -16,6 +19,23 @@ use axum::{
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid; 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( async fn require_channel_member(
state: &AppState, state: &AppState,
channel_id: Uuid, channel_id: Uuid,
@@ -54,16 +74,26 @@ async fn require_channel_member(
tag = "Channels" tag = "Channels"
)] )]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<ChannelQueryParams>, Query(filters): Query<ChannelQueryParams>,
) -> Result<Json<Vec<ChannelResponse>>, HTTPError> { ) -> 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 params = mapper::query_params_to_channel_filter(filters);
let channels = state.repositories.channel.filter(params).await?; let channels = state.repositories.channel.filter(params).await?;
Ok(Json( Ok(Json(
channels {
.into_iter() let mut visible = Vec::new();
.map(mapper::channel_model_to_channel_response) for channel in channels {
.collect(), 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>, State(state): State<AppState>,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
) -> Result<Json<ReadStateResponse>, HTTPError> { ) -> 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 let read_state = state
.repositories .repositories
.read_state .read_state
@@ -120,7 +150,7 @@ pub async fn set_read_state(
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
Json(payload): Json<SetReadStateRequest>, Json(payload): Json<SetReadStateRequest>,
) -> Result<Json<ReadStateResponse>, HTTPError> { ) -> 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 { if let Some(message_id) = payload.last_read_message_id {
let message = state let message = state
@@ -173,6 +203,7 @@ pub async fn set_read_state(
tag = "Channels" tag = "Channels"
)] )]
pub async fn get_by_id( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<ChannelResponse>, HTTPError> { ) -> Result<Json<ChannelResponse>, HTTPError> {
@@ -182,6 +213,7 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_channel_permission(&state, &user, id, ChannelPermission::READ_CHANNEL).await?;
Ok(Json(mapper::channel_model_to_channel_response(channel))) Ok(Json(mapper::channel_model_to_channel_response(channel)))
} }
@@ -195,9 +227,11 @@ pub async fn get_by_id(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn list_permissions( pub async fn list_permissions(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(channel_id): Path<Uuid>, Path(channel_id): Path<Uuid>,
) -> Result<Json<ChannelPermissionsResponse>, HTTPError> { ) -> Result<Json<ChannelPermissionsResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
state state
.repositories .repositories
.channel .channel
@@ -227,7 +261,7 @@ pub async fn list_permissions(
) )
)] )]
pub async fn create( pub async fn create(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<CreateChannelRequest>, Json(payload): Json<CreateChannelRequest>,
) -> Result<(StatusCode, Json<ChannelResponse>), HTTPError> { ) -> Result<(StatusCode, Json<ChannelResponse>), HTTPError> {
@@ -239,16 +273,20 @@ pub async fn create(
.get_by_id(server_id) .get_by_id(server_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?; .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 // Vérifier que la catégorie existe si fournie
if let Some(category_id) = payload.category_id { if let Some(category_id) = payload.category_id {
state let category = state
.repositories .repositories
.category .category
.get_by_id(category_id) .get_by_id(category_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?; .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?; let channel = state.services.channel.create_channel(payload).await?;
@@ -278,18 +316,23 @@ pub async fn create(
) )
)] )]
pub async fn update( pub async fn update(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateChannelRequest>, Json(payload): Json<UpdateChannelRequest>,
) -> Result<Json<ChannelResponse>, HTTPError> { ) -> Result<Json<ChannelResponse>, HTTPError> {
// Vérifier l'existence // Vérifier l'existence
state let original = state
.repositories .repositories
.channel .channel
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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 // Vérifier que le serveur existe si fourni
if let Some(server_id) = payload.server_id { 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 // Vérifier que la catégorie existe si fournie
if let Some(category_id) = payload.category_id { if let Some(category_id) = payload.category_id {
state let category = state
.repositories .repositories
.category .category
.get_by_id(category_id) .get_by_id(category_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Category not found".to_string()))?; .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?; let channel = state.services.channel.update_channel(id, payload).await?;
@@ -334,10 +378,11 @@ pub async fn update(
) )
)] )]
pub async fn delete( pub async fn delete(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_channel_manager(&state, &user, id).await?;
if state.services.channel.delete_channel(id).await? { if state.services.channel.delete_channel(id).await? {
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
@@ -361,9 +406,11 @@ pub async fn delete(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn get_user_permission( pub async fn get_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, user_id)): Path<(Uuid, Uuid)>, Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> { ) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
let permission = state let permission = state
.repositories .repositories
.channel .channel
@@ -392,10 +439,12 @@ pub async fn get_user_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn set_user_permission( pub async fn set_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, user_id)): Path<(Uuid, Uuid)>, Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetChannelPermissionRequest>, Json(payload): Json<SetChannelPermissionRequest>,
) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> { ) -> Result<Json<ChannelUserPermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
state state
.services .services
.channel .channel
@@ -430,9 +479,11 @@ pub async fn set_user_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn remove_user_permission( pub async fn remove_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, user_id)): Path<(Uuid, Uuid)>, Path((channel_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
if state if state
.repositories .repositories
.channel .channel
@@ -468,9 +519,11 @@ pub async fn remove_user_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn get_role_permission( pub async fn get_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, role_id)): Path<(Uuid, Uuid)>, Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> { ) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
let permission = state let permission = state
.repositories .repositories
.channel .channel
@@ -499,10 +552,12 @@ pub async fn get_role_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn set_role_permission( pub async fn set_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, role_id)): Path<(Uuid, Uuid)>, Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
Json(payload): Json<SetChannelPermissionRequest>, Json(payload): Json<SetChannelPermissionRequest>,
) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> { ) -> Result<Json<ChannelRolePermissionResponse>, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
state state
.services .services
.channel .channel
@@ -537,9 +592,11 @@ pub async fn set_role_permission(
tag = "Channel Permissions" tag = "Channel Permissions"
)] )]
pub async fn remove_role_permission( pub async fn remove_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((channel_id, role_id)): Path<(Uuid, Uuid)>, Path((channel_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_channel_manager(&state, &user, channel_id).await?;
if state if state
.repositories .repositories
.channel .channel
+1
View File
@@ -25,6 +25,7 @@ pub fn channel_model_to_channel_response_with_permission(
updated_at: model.updated_at, updated_at: model.updated_at,
unread_count: None, unread_count: None,
permission, permission,
voice_participants: None,
} }
} }
+3
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
mod tests;
+126
View File
@@ -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
);
}
+2 -5
View File
@@ -108,12 +108,9 @@ async fn create_channel(state: &AppState, ids: &[Uuid]) -> Result<channel::Model
.services .services
.realtime_registry .realtime_registry
.set_channel_users(channel.id, ids.iter().copied()); .set_channel_users(channel.id, ids.iter().copied());
state.event_bus.emit( state.event_bus.emit(ChannelCreatedEvent {
"channel_created",
ChannelCreatedEvent {
channel: channel.clone(), channel: channel.clone(),
}, });
);
Ok(channel) Ok(channel)
} }
+38
View File
@@ -10,3 +10,41 @@ pub fn router() -> Router<AppState> {
.route("/conversations", get(handlers::list).post(handlers::create)) .route("/conversations", get(handlers::list).post(handlers::create))
.route("/conversations/{id}/fork", post(handlers::fork)) .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}");
}
}
}
+43 -14
View File
@@ -1,4 +1,7 @@
use crate::domain::events::emoji::{EmojiCreatedEvent, EmojiDeletedEvent, EmojiUpdatedEvent}; 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::services::media;
use crate::{ use crate::{
core::state::AppState, 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")] #[utoipa::path(get, path = "/emojis", params(EmojiQueryParams), responses((status = 200, body = [crate::domain::dto::emoji::EmojiResponse])), tag = "Emojis")]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(query): Query<EmojiQueryParams>, Query(query): Query<EmojiQueryParams>,
) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> { ) -> Result<Json<Vec<crate::domain::dto::emoji::EmojiResponse>>, HTTPError> {
let mut result = Vec::new(); let mut result = Vec::new();
for emoji in state.repositories.emoji.list(query.server_id).await? { 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)) Ok(Json(result))
} }
#[utoipa::path(get, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 200, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] #[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( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<crate::domain::dto::emoji::EmojiResponse>, HTTPError> { ) -> Result<Json<crate::domain::dto::emoji::EmojiResponse>, HTTPError> {
@@ -52,11 +73,15 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::response(emoji)))
} }
#[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")] #[utoipa::path(post, path = "/emojis", responses((status = 201, body = crate::domain::dto::emoji::EmojiResponse)), tag = "Emojis")]
pub async fn create( pub async fn create(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
mut multipart: Multipart, mut multipart: Multipart,
) -> Result<(StatusCode, Json<crate::domain::dto::emoji::EmojiResponse>), HTTPError> { ) -> Result<(StatusCode, Json<crate::domain::dto::emoji::EmojiResponse>), HTTPError> {
@@ -112,6 +137,7 @@ pub async fn create(
"file is required for custom emojis".into(), "file is required for custom emojis".into(),
)); ));
} }
require_manage(&state, &user, server_id).await?;
if let Some(server_id) = server_id { if let Some(server_id) = server_id {
state state
.repositories .repositories
@@ -155,17 +181,15 @@ pub async fn create(
..Default::default() ..Default::default()
}; };
let created = state.services.emoji.create(model, name).await?; let created = state.services.emoji.create(model, name).await?;
state.event_bus.emit( state.event_bus.emit(EmojiCreatedEvent {
"emoji_created",
EmojiCreatedEvent {
emoji: created.clone(), emoji: created.clone(),
}, });
);
Ok((StatusCode::CREATED, Json(mapper::response(created)))) 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")] #[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( pub async fn update(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(payload): Json<UpdateEmojiRequest>, Json(payload): Json<UpdateEmojiRequest>,
@@ -176,6 +200,10 @@ pub async fn update(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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_server_id = payload.server_id.or(existing.server_id);
let target_name = payload.name.as_deref().unwrap_or(&existing.name); let target_name = payload.name.as_deref().unwrap_or(&existing.name);
state state
@@ -201,13 +229,10 @@ pub async fn update(
active.name = Set(EmojiService::normalize_name(&name)?); active.name = Set(EmojiService::normalize_name(&name)?);
} }
let updated = state.repositories.emoji.update(active).await?; let updated = state.repositories.emoji.update(active).await?;
state.event_bus.emit( state.event_bus.emit(EmojiUpdatedEvent {
"emoji_updated",
EmojiUpdatedEvent {
previous, previous,
emoji: updated.clone(), emoji: updated.clone(),
}, });
);
Ok(Json(mapper::response(updated))) 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")] #[utoipa::path(delete, path = "/emojis/{id}", params(("id" = Uuid, Path)), responses((status = 204)), tag = "Emojis")]
pub async fn delete( pub async fn delete(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
@@ -234,6 +260,7 @@ pub async fn delete(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_manage(&state, &user, model.server_id).await?;
let deleted = state.repositories.emoji.delete(id).await?; let deleted = state.repositories.emoji.delete(id).await?;
if deleted { if deleted {
EmojiService::remove_asset( EmojiService::remove_asset(
@@ -241,9 +268,7 @@ pub async fn delete(
model.file_path.as_deref(), model.file_path.as_deref(),
) )
.await; .await;
state state.event_bus.emit(EmojiDeletedEvent { emoji: model });
.event_bus
.emit("emoji_deleted", EmojiDeletedEvent { emoji: model });
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
Err(HTTPError::NotFound) Err(HTTPError::NotFound)
@@ -251,6 +276,7 @@ pub async fn delete(
} }
pub async fn asset( pub async fn asset(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Response, HTTPError> { ) -> Result<Response, HTTPError> {
@@ -260,6 +286,9 @@ pub async fn asset(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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 path = model.file_path.ok_or(HTTPError::NotFound)?;
let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path)) let bytes = tokio::fs::read(PathBuf::from(&state.config.media.root).join(path))
.await .await
+161
View File
@@ -13,3 +13,164 @@ pub fn router() -> Router<AppState> {
) )
.route("/emojis/{id}/asset", get(handlers::asset)) .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
);
}
}
}
+43 -36
View File
@@ -1,4 +1,7 @@
use crate::core::AppState; use crate::core::AppState;
use crate::domain::events::category::{
CategoryCreatedEvent, CategoryDeletedEvent, CategoryUpdatedEvent,
};
use crate::domain::events::channel::{ use crate::domain::events::channel::{
ChannelCreatedEvent, ChannelDeletedEvent, ChannelUpdatedEvent, ChannelCreatedEvent, ChannelDeletedEvent, ChannelUpdatedEvent,
}; };
@@ -7,8 +10,10 @@ use crate::domain::events::message::{
MessageCreatedEvent, MessageDeletedEvent, MessageReactionAddedEvent, MessageCreatedEvent, MessageDeletedEvent, MessageReactionAddedEvent,
MessageReactionRemovedEvent, MessageUpdatedEvent, MessageReactionRemovedEvent, MessageUpdatedEvent,
}; };
use crate::domain::events::server::{ServerCreatedEvent, ServerDeletedEvent, ServerUpdatedEvent};
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent; 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::repositories::Repositories;
use crate::routes::category::mapper::category_model_to_category_response; use crate::routes::category::mapper::category_model_to_category_response;
use crate::routes::channel::mapper::channel_model_to_channel_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>) { pub fn start(self: &Arc<Self>, event_bus: Arc<EventBus>) {
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let attachments = router let attachments = router
@@ -142,7 +156,7 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let reactions = router let reactions = router
@@ -171,7 +185,7 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let users = router let users = router
@@ -185,9 +199,7 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); let router = Arc::clone(self);
event_bus.on_async::<MessageReactionAddedEvent, _, _>( event_bus.on_async::<MessageReactionAddedEvent>(move |event| {
"message_reaction_added",
move |event| {
let router = Arc::clone(&router); let router = Arc::clone(&router);
async move { async move {
let users = router let users = router
@@ -201,13 +213,10 @@ impl RealtimeRouter {
reaction_model_to_response(event.reaction), reaction_model_to_response(event.reaction),
); );
} }
}, });
);
let router = Arc::clone(self); let router = Arc::clone(self);
event_bus.on_async::<MessageReactionRemovedEvent, _, _>( event_bus.on_async::<MessageReactionRemovedEvent>(move |event| {
"message_reaction_removed",
move |event| {
let router = Arc::clone(&router); let router = Arc::clone(&router);
async move { async move {
let users = router let users = router
@@ -221,11 +230,10 @@ impl RealtimeRouter {
reaction_model_to_response(event.reaction), reaction_model_to_response(event.reaction),
); );
} }
}, });
);
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
if let Err(error) = router if let Err(error) = router
@@ -251,7 +259,7 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let mut users = router let mut users = router
@@ -274,7 +282,7 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let users = router let users = router
@@ -292,9 +300,10 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let category = event.category;
let users = router.server_users(category.server_id).await; let users = router.server_users(category.server_id).await;
router.gateway.send_to_users( router.gateway.send_to_users(
users, users,
@@ -306,9 +315,10 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let category = event.category;
let users = router.server_users(category.server_id).await; let users = router.server_users(category.server_id).await;
router.gateway.send_to_users( router.gateway.send_to_users(
users, users,
@@ -320,9 +330,10 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let category = event.category;
let users = router.server_users(category.server_id).await; let users = router.server_users(category.server_id).await;
router router
.gateway .gateway
@@ -331,9 +342,10 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let server = event.server;
let users = router.server_users(server.id).await; let users = router.server_users(server.id).await;
router.gateway.send_to_users( router.gateway.send_to_users(
users, users,
@@ -345,9 +357,10 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let server = event.server;
let users = router.server_users(server.id).await; let users = router.server_users(server.id).await;
router.gateway.send_to_users( router.gateway.send_to_users(
users, users,
@@ -359,22 +372,17 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); let router = Arc::clone(self);
event_bus.on_async::<(server::Model, Vec<Uuid>), _, _>( event_bus.on_async::<ServerDeletedEvent>(move |event| {
"server_deleted",
move |(server, users)| {
let router = Arc::clone(&router); let router = Arc::clone(&router);
async move { async move {
router router
.gateway .gateway
.send_to_users(users, "Server", "remove", server.id); .send_to_users(event.audience, "Server", "remove", event.server.id);
} }
}, });
);
let router = Arc::clone(self); let router = Arc::clone(self);
event_bus.on_async::<ServerTreeInvalidatedEvent, _, _>( event_bus.on_async::<ServerTreeInvalidatedEvent>(move |event| {
"server_tree_invalidated",
move |event| {
let router = Arc::clone(&router); let router = Arc::clone(&router);
async move { async move {
let users = match event.user_ids { let users = match event.user_ids {
@@ -385,11 +393,10 @@ impl RealtimeRouter {
.gateway .gateway
.send_to_users(users, "ServerTree", "refresh", event.server_id); .send_to_users(users, "ServerTree", "refresh", event.server_id);
} }
}, });
);
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let users = router.emoji_users(event.emoji.server_id).await; let users = router.emoji_users(event.emoji.server_id).await;
@@ -403,7 +410,7 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let mut users = router.emoji_users(event.emoji.server_id).await; let mut users = router.emoji_users(event.emoji.server_id).await;
@@ -418,7 +425,7 @@ impl RealtimeRouter {
}); });
let router = Arc::clone(self); 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); let router = Arc::clone(&router);
async move { async move {
let users = router.emoji_users(event.emoji.server_id).await; let users = router.emoji_users(event.emoji.server_id).await;
+112 -41
View File
@@ -6,36 +6,59 @@ use crate::domain::dto::message::{
use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse}; use crate::domain::dto::reaction::{CreateReactionRequest, DeleteReactionQuery, ReactionResponse};
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; 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 crate::routes::message::mapper;
use axum::{ use axum::{
Json, Json,
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
}; };
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
use uuid::Uuid; use uuid::Uuid;
pub(crate) async fn can_access( pub(crate) async fn require_channel_permission(
state: &AppState, state: &AppState,
channel_id: Uuid, channel_id: Uuid,
user_id: Uuid, user_id: Uuid,
) -> Result<bool, HTTPError> { is_superuser: bool,
let Some(channel) = channel::Entity::find_by_id(channel_id) required: ChannelPermission,
.one(&state.db) ) -> Result<(), HTTPError> {
.await? let _ = is_superuser;
else { if check_channel_permission(state, user_id, channel_id, required).await? {
return Ok(false); Ok(())
}; } else {
if channel.channel_type != channel::ChannelType::DM { Err(HTTPError::Forbidden)
return Ok(true); }
}
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 /// 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); let params = mapper::query_params_to_message_filter(filters);
if let Some(channel_id) = params.channel_id { let channel_id = params
if !can_access(&state, channel_id, user.id).await? { .channel_id
return Err(HTTPError::Forbidden); .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 page = state.repositories.message.filter(params).await?;
let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect(); let message_ids: Vec<_> = page.messages.iter().map(|message| message.id).collect();
let mut reactions = state let mut reactions = state
@@ -126,9 +155,14 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if !can_access(&state, message.channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} message.channel_id,
user.id,
user.is_superuser,
ChannelPermission::READ_CHANNEL,
)
.await?;
let reactions = state let reactions = state
.services .services
@@ -180,9 +214,11 @@ pub async fn create(
.get_by_id(payload.channel_id) .get_by_id(payload.channel_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Channel not found".to_string()))?; .ok_or(HTTPError::BadRequest("Channel not found".to_string()))?;
if !can_access(&state, channel.id, user.id).await? { let mut required = ChannelPermission::SEND_MESSAGE;
return Err(HTTPError::Forbidden); 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() { if payload.content.trim().is_empty() && payload.file_ids.is_empty() {
return Err(HTTPError::BadRequest( return Err(HTTPError::BadRequest(
@@ -192,7 +228,7 @@ pub async fn create(
// Optionnel: vérifier reply_to_id // Optionnel: vérifier reply_to_id
if let Some(reply_id) = payload.reply_to_id { if let Some(reply_id) = payload.reply_to_id {
state let parent = state
.repositories .repositories
.message .message
.get_by_id(reply_id) .get_by_id(reply_id)
@@ -200,6 +236,11 @@ pub async fn create(
.ok_or(HTTPError::BadRequest( .ok_or(HTTPError::BadRequest(
"Parent message not found".to_string(), "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 let message = state
@@ -274,9 +315,19 @@ pub async fn update(
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
// Vérifier que l'utilisateur est l'auteur // Vérifier que l'utilisateur est l'auteur
if message.user_id != user.id && !user.is_superuser { let required = if message.user_id == user.id {
return Err(HTTPError::Forbidden); 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 let message = state
.services .services
@@ -332,9 +383,14 @@ pub async fn add_reaction(
.get_by_id(message_id) .get_by_id(message_id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if !can_access(&state, message.channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} message.channel_id,
user.id,
user.is_superuser,
ChannelPermission::READ_CHANNEL | ChannelPermission::ADD_REACTIONS,
)
.await?;
let (reaction, created) = state let (reaction, created) = state
.services .services
.message_reaction .message_reaction
@@ -378,9 +434,14 @@ pub async fn remove_reaction(
.get_by_id(message_id) .get_by_id(message_id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if !can_access(&state, message.channel_id, user.id).await? { require_channel_permission(
return Err(HTTPError::Forbidden); &state,
} message.channel_id,
user.id,
user.is_superuser,
ChannelPermission::READ_CHANNEL,
)
.await?;
state state
.services .services
.message_reaction .message_reaction
@@ -420,9 +481,19 @@ pub async fn delete(
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
if message.user_id != user.id && !user.is_superuser { let required = if message.user_id == user.id {
return Err(HTTPError::Forbidden); 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 let attachment_ids: Vec<_> = state
.repositories .repositories
+324
View File
@@ -20,3 +20,327 @@ pub fn router() -> Router<AppState> {
axum::routing::delete(handlers::remove_reaction), 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
);
}
}
+3 -1
View File
@@ -15,6 +15,7 @@ pub mod gateway;
pub mod message; pub mod message;
pub mod openapi; pub mod openapi;
pub mod role; pub mod role;
pub mod rtc;
pub mod server; pub mod server;
pub mod server_item_order; pub mod server_item_order;
pub mod user; pub mod user;
@@ -44,7 +45,8 @@ pub fn router() -> OxRouter {
let ws_routes = Router::new() let ws_routes = Router::new()
.merge(gateway::routes::router()) .merge(gateway::routes::router())
.merge(voice::routes::router()); .merge(voice::routes::router())
.merge(rtc::routes::router());
Router::new() Router::new()
.nest("/api", api_routes) .nest("/api", api_routes)
+12 -30
View File
@@ -6,6 +6,7 @@ use crate::domain::dto::user::UserResponse;
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission; use crate::permissions::ServerPermission;
use crate::routes::server::handlers::require_server_permission;
use crate::routes::role::mapper; use crate::routes::role::mapper;
use crate::routes::user::mapper as user_mapper; use crate::routes::user::mapper as user_mapper;
use axum::{ use axum::{
@@ -15,36 +16,15 @@ use axum::{
}; };
use uuid::Uuid; 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")] #[utoipa::path(get, path = "/roles", params(RoleQueryParams), responses((status = 200, body = [RoleResponse])), tag = "Roles")]
pub async fn get_all( pub async fn get_all(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<RoleQueryParams>, Query(filters): Query<RoleQueryParams>,
) -> Result<Json<Vec<RoleResponse>>, HTTPError> { ) -> 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 { let roles = match filters.server_id {
Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?, Some(server_id) => state.repositories.role.get_all_by_server(server_id).await?,
None => state.repositories.role.get_all().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")] #[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( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<RoleResponse>, HTTPError> { ) -> Result<Json<RoleResponse>, HTTPError> {
@@ -69,6 +50,7 @@ pub async fn get_by_id(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::role_model_to_role_response(role)))
} }
@@ -84,7 +66,7 @@ pub async fn create(
.get_by_id(payload.server_id) .get_by_id(payload.server_id)
.await? .await?
.ok_or(HTTPError::BadRequest("Server not found".to_string()))?; .ok_or(HTTPError::BadRequest("Server not found".to_string()))?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
payload.server_id, payload.server_id,
@@ -116,7 +98,7 @@ pub async fn update(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -148,7 +130,7 @@ pub async fn delete(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -174,7 +156,7 @@ pub async fn get_members(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -201,7 +183,7 @@ pub async fn add_member(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
@@ -235,7 +217,7 @@ pub async fn remove_member(
.get_by_id(id) .get_by_id(id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
require_permission( require_server_permission(
&state, &state,
&user, &user,
role.server_id, role.server_id,
+3
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
mod tests;
+89
View File
@@ -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
);
}
+34
View File
@@ -0,0 +1,34 @@
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;
pub async fn ws_handler(
ws: WebSocketUpgrade,
Path(channel_id): Path<Uuid>,
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)?;
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)
}
+2
View File
@@ -0,0 +1,2 @@
mod handlers;
pub mod routes;
+9
View File
@@ -0,0 +1,9 @@
use super::handlers;
use crate::core::AppState;
use axum::Router;
use axum::routing::get;
pub fn router() -> Router<AppState> {
Router::new().route("/rtc/{channel_id}", get(handlers::ws_handler))
}
+45 -32
View File
@@ -1,12 +1,13 @@
use crate::core::state::AppState; use crate::core::state::AppState;
use crate::domain::dto::server::{ use crate::domain::dto::server::{
CreateServerRequest, JoinServerRequest, ServerResponse, ServerRolePermissionResponse, CreateServerRequest, JoinServerRequest, ServerResponse, ServerRolePermissionResponse,
ServerTreeResponse, ServerTreeResponse, ServerUserPermissionResponse, SetServerPermissionRequest,
ServerUserPermissionResponse, SetServerPermissionRequest, UpdateServerRequest, UpdateServerRequest,
}; };
use crate::http::context::{CurrentUser, Superuser}; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission; use crate::permissions::ServerPermission;
use crate::http::permissions::check_server_permission;
use crate::routes::server::mapper; use crate::routes::server::mapper;
use axum::{ use axum::{
Json, Json,
@@ -15,25 +16,13 @@ use axum::{
}; };
use uuid::Uuid; use uuid::Uuid;
async fn require_server_permission( pub(crate) async fn require_server_permission(
state: &AppState, state: &AppState,
user: &CurrentUser, user: &CurrentUser,
server_id: Uuid, server_id: Uuid,
permission: ServerPermission, permission: ServerPermission,
) -> Result<(), HTTPError> { ) -> Result<(), HTTPError> {
if user.is_superuser { if check_server_permission(state, user.id, server_id, permission).await? {
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(()) Ok(())
} else { } else {
Err(HTTPError::Forbidden) Err(HTTPError::Forbidden)
@@ -90,9 +79,11 @@ pub async fn get_all(
tag = "Servers" tag = "Servers"
)] )]
pub async fn get_by_id( pub async fn get_by_id(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<Json<ServerResponse>, HTTPError> { ) -> Result<Json<ServerResponse>, HTTPError> {
state.repositories.server.get_user(id, user.id).await?.ok_or(HTTPError::NotFound)?;
let server = state let server = state
.repositories .repositories
.server .server
@@ -229,10 +220,11 @@ pub async fn update(
) )
)] )]
pub async fn delete( pub async fn delete(
_admin: Superuser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
require_server_permission(&state, &user, id, ServerPermission::MANAGE_SERVER).await?;
if state.services.server.delete_server(id).await? { if state.services.server.delete_server(id).await? {
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} else { } else {
@@ -256,9 +248,11 @@ pub async fn delete(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn get_user_permission( pub async fn get_user_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, user_id)): Path<(Uuid, Uuid)>, Path((server_id, user_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ServerUserPermissionResponse>, HTTPError> { ) -> Result<Json<ServerUserPermissionResponse>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
let permission = state let permission = state
.repositories .repositories
.server .server
@@ -278,9 +272,11 @@ pub async fn get_user_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn list_user_permissions( pub async fn list_user_permissions(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path(server_id): Path<Uuid>, Path(server_id): Path<Uuid>,
) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> { ) -> Result<Json<Vec<ServerUserPermissionResponse>>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_MEMBERS).await?;
state state
.repositories .repositories
.server .server
@@ -336,10 +332,6 @@ pub async fn set_user_permission(
.server .server
.set_user_permission(server_id, user_id, payload.permissions) .set_user_permission(server_id, user_id, payload.permissions)
.await?; .await?;
state
.event_bus
.emit("server_user_permission_updated", (server_id, user_id));
let permission = state let permission = state
.repositories .repositories
.server .server
@@ -347,6 +339,13 @@ pub async fn set_user_permission(
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::server_user_permission_to_response(permission)))
} }
@@ -386,9 +385,13 @@ pub async fn remove_user_permission(
.server .server
.remove_user_permission(server_id, user_id) .remove_user_permission(server_id, user_id)
.await?; .await?;
state state.event_bus.emit(
.event_bus crate::domain::events::server_permission::ServerUserPermissionUpdatedEvent {
.emit("server_user_permission_updated", (server_id, user_id)); server_id,
user_id,
permission: None,
},
);
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -409,9 +412,11 @@ pub async fn remove_user_permission(
tag = "Server Permissions" tag = "Server Permissions"
)] )]
pub async fn get_role_permission( pub async fn get_role_permission(
user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Path((server_id, role_id)): Path<(Uuid, Uuid)>, Path((server_id, role_id)): Path<(Uuid, Uuid)>,
) -> Result<Json<ServerRolePermissionResponse>, HTTPError> { ) -> Result<Json<ServerRolePermissionResponse>, HTTPError> {
require_server_permission(&state, &user, server_id, ServerPermission::MANAGE_ROLES).await?;
let permission = state let permission = state
.repositories .repositories
.server .server
@@ -449,10 +454,6 @@ pub async fn set_role_permission(
.server .server
.set_role_permission(server_id, role_id, payload.permissions) .set_role_permission(server_id, role_id, payload.permissions)
.await?; .await?;
state
.event_bus
.emit("server_role_permission_updated", (role_id, server_id));
let permission = state let permission = state
.repositories .repositories
.server .server
@@ -460,6 +461,13 @@ pub async fn set_role_permission(
.await? .await?
.ok_or(HTTPError::NotFound)?; .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))) Ok(Json(mapper::server_role_permission_to_response(permission)))
} }
@@ -499,9 +507,13 @@ pub async fn remove_role_permission(
.server .server
.remove_role_permission(server_id, role_id) .remove_role_permission(server_id, role_id)
.await?; .await?;
state state.event_bus.emit(
.event_bus crate::domain::events::server_permission::ServerRolePermissionUpdatedEvent {
.emit("server_role_permission_updated", (role_id, server_id)); server_id,
role_id,
permission: None,
},
);
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
@@ -543,5 +555,6 @@ pub async fn get_tree(
tree.channels, tree.channels,
tree.categories, tree.categories,
tree.unread_counts, tree.unread_counts,
&state.rtc.rooms,
))) )))
} }
+25 -1
View File
@@ -2,8 +2,9 @@ use crate::domain::dto::server::{
CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse, CreateServerRequest, ServerExplorerItemResponse, ServerResponse, ServerRolePermissionResponse,
ServerTreeResponse, ServerUserPermissionResponse, UpdateServerRequest, 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::repositories::types::{CategoryWithPermissions, ChannelWithPermissions};
use crate::rtc::VoiceRoomManager;
use crate::routes::category::mapper::category_model_to_category_response_with_permission; 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 crate::routes::channel::mapper::channel_model_to_channel_response_with_permission;
use sea_orm::Set; use sea_orm::Set;
@@ -77,6 +78,7 @@ pub fn build_server_tree(
channels: Vec<ChannelWithPermissions>, channels: Vec<ChannelWithPermissions>,
categories: Vec<CategoryWithPermissions>, categories: Vec<CategoryWithPermissions>,
unread_counts: HashMap<Uuid, u64>, unread_counts: HashMap<Uuid, u64>,
rooms: &VoiceRoomManager,
) -> ServerTreeResponse { ) -> ServerTreeResponse {
let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders let order_map: HashMap<(Option<Uuid>, Uuid), i64> = orders
.into_iter() .into_iter()
@@ -135,6 +137,7 @@ pub fn build_server_tree(
Some(chan_perm_bits), Some(chan_perm_bits),
); );
response.unread_count = Some(*unread_counts.get(&response.id).unwrap_or(&0)); 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 response
}) })
.collect(); .collect();
@@ -156,6 +159,7 @@ pub fn build_server_tree(
Some(chan_perm_bits), Some(chan_perm_bits),
); );
chan_response.unread_count = Some(*unread_counts.get(&chan_response.id).unwrap_or(&0)); 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(( root_items.push((
ServerExplorerItemResponse::Channel(chan_response), ServerExplorerItemResponse::Channel(chan_response),
order_key, order_key,
@@ -250,6 +254,7 @@ mod tests {
], ],
vec![category], vec![category],
HashMap::new(), HashMap::new(),
&VoiceRoomManager::new(),
); );
let ServerExplorerItemResponse::Category(_, channels) = &response.items[0] else { 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[0].name.as_deref(), Some("first"));
assert_eq!(channels[1].name.as_deref(), Some("second")); 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
View File
@@ -3,3 +3,6 @@ pub mod handlers;
pub mod mapper; pub mod mapper;
pub mod routes; pub mod routes;
pub mod service; pub mod service;
#[cfg(test)]
pub(crate) mod tests;
+54
View File
@@ -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());
}
+6 -6
View File
@@ -3,6 +3,8 @@ use crate::domain::dto::server_item_order::ReorderServerItemRequest;
use crate::domain::events::server_tree::ServerTreeInvalidatedEvent; use crate::domain::events::server_tree::ServerTreeInvalidatedEvent;
use crate::http::context::CurrentUser; use crate::http::context::CurrentUser;
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::permissions::ServerPermission;
use crate::routes::server::handlers::require_server_permission;
use axum::http::StatusCode; use axum::http::StatusCode;
use axum::{Json, extract::State}; use axum::{Json, extract::State};
@@ -15,7 +17,7 @@ use axum::{Json, extract::State};
security(("bearerAuth" = [])) security(("bearerAuth" = []))
)] )]
pub async fn reorder( pub async fn reorder(
_user: CurrentUser, user: CurrentUser,
State(state): State<AppState>, State(state): State<AppState>,
Json(payload): Json<ReorderServerItemRequest>, Json(payload): Json<ReorderServerItemRequest>,
) -> Result<StatusCode, HTTPError> { ) -> Result<StatusCode, HTTPError> {
@@ -26,15 +28,13 @@ pub async fn reorder(
.get_by_id(server_id) .get_by_id(server_id)
.await? .await?
.ok_or(HTTPError::NotFound)?; .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.services.server_order.reorder(payload).await?;
state.event_bus.emit( state.event_bus.emit(ServerTreeInvalidatedEvent {
"server_tree_invalidated",
ServerTreeInvalidatedEvent {
server_id, server_id,
user_ids: None, user_ids: None,
}, });
);
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
+3
View File
@@ -1,2 +1,5 @@
pub mod handlers; pub mod handlers;
pub mod routes; pub mod routes;
#[cfg(test)]
mod tests;
+35
View File
@@ -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);
}
+1
View File
@@ -28,6 +28,7 @@ use uuid::Uuid;
) )
)] )]
pub async fn get_all( pub async fn get_all(
_admin: Superuser,
State(state): State<AppState>, State(state): State<AppState>,
Query(filters): Query<UserQueryParams>, Query(filters): Query<UserQueryParams>,
) -> Result<Json<Vec<UserResponse>>, HTTPError> { ) -> Result<Json<Vec<UserResponse>>, HTTPError> {
+35
View File
@@ -13,3 +13,38 @@ pub fn router() -> Router<AppState> {
.delete(handlers::delete), .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}");
}
}
}
+2
View File
@@ -0,0 +1,2 @@
Recréer ce module de 0, afin de le comprendre à 100% (dans le dossier RTC)
+37
View File
@@ -0,0 +1,37 @@
## Parcours technique RTC
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`.
### Entrée et création du client
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.
### 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.
+587
View File
@@ -0,0 +1,587 @@
use super::messages::{VoiceClientMessage, VoiceServerMessage};
use super::{RoomEvent, VoiceParticipant, VoiceRoomManager};
use crate::models::{channel, user};
use axum::extract::ws::Message;
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)]
pub struct RTCClient {
pub user: Arc<user::Model>,
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 {
pub fn new(
user: user::Model,
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)),
}
}
/// 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,
}
}
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"));
}
}
+21
View File
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "kebab-case")]
pub enum VoiceClientMessage {
#[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 { sdp: String },
SdpOffer { sdp: String },
SourceLeft { id: uuid::Uuid },
IceCandidate { candidate: String },
Error { message: String },
}
+178
View File
@@ -0,0 +1,178 @@
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use crate::metrics::{Metrics, MetricsSnapshot};
/// Compteurs atomiques pour les métriques de la voix / WebRTC.
#[derive(Debug, Default)]
pub struct VoiceMetrics {
/// Nombre total de datagrammes / paquets reçus.
pub packets_received: AtomicU64,
/// Volume total d'octets reçus.
pub bytes_received: AtomicU64,
/// Nombre total de datagrammes / paquets retransmis.
pub packets_sent: AtomicU64,
/// Volume total d'octets retransmis.
pub bytes_sent: AtomicU64,
/// Paquets ignorés ou rejetés.
pub packets_dropped: AtomicU64,
/// Nombre d'erreurs d'émission.
pub send_errors: AtomicU64,
/// Nombre d'erreurs de réception.
pub recv_errors: AtomicU64,
}
impl VoiceMetrics {
/// Crée un jeu de métriques vide enveloppé dans un [`Arc`].
pub fn new() -> Arc<Self> {
Arc::new(Self::default())
}
/// Enregistre la réception d'un paquet de `bytes` octets.
#[inline]
pub fn inc_received(&self, bytes: u64) {
self.packets_received.fetch_add(1, Ordering::Relaxed);
self.bytes_received.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre l'émission d'un paquet de `bytes` octets.
#[inline]
pub fn inc_sent(&self, bytes: u64) {
self.packets_sent.fetch_add(1, Ordering::Relaxed);
self.bytes_sent.fetch_add(bytes, Ordering::Relaxed);
}
/// Enregistre un paquet ignoré.
#[inline]
pub fn inc_dropped(&self) {
self.packets_dropped.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec d'émission non fatal.
#[inline]
pub fn inc_send_error(&self) {
self.send_errors.fetch_add(1, Ordering::Relaxed);
}
/// Enregistre un échec de réception.
#[inline]
pub fn inc_recv_error(&self) {
self.recv_errors.fetch_add(1, Ordering::Relaxed);
}
/// Prend un instantané cohérent de tous les compteurs.
pub fn snapshot(&self) -> VoiceMetricsSnapshot {
VoiceMetricsSnapshot {
taken_at: Instant::now(),
packets_received: self.packets_received.load(Ordering::Relaxed),
bytes_received: self.bytes_received.load(Ordering::Relaxed),
packets_sent: self.packets_sent.load(Ordering::Relaxed),
bytes_sent: self.bytes_sent.load(Ordering::Relaxed),
packets_dropped: self.packets_dropped.load(Ordering::Relaxed),
send_errors: self.send_errors.load(Ordering::Relaxed),
recv_errors: self.recv_errors.load(Ordering::Relaxed),
}
}
}
impl Metrics for VoiceMetrics {
type Snapshot = VoiceMetricsSnapshot;
fn snapshot(&self) -> VoiceMetricsSnapshot {
self.snapshot()
}
}
/// Lecture cohérente de l'ensemble des compteurs à un instant T.
#[derive(Debug, Clone, Copy)]
pub struct VoiceMetricsSnapshot {
pub taken_at: Instant,
pub packets_received: u64,
pub bytes_received: u64,
pub packets_sent: u64,
pub bytes_sent: u64,
pub packets_dropped: u64,
pub send_errors: u64,
pub recv_errors: u64,
}
impl VoiceMetricsSnapshot {
/// Calcule les taux moyens par seconde depuis un snapshot précédent.
pub fn rates_since(&self, previous: &Self) -> VoiceRates {
let secs = self
.taken_at
.duration_since(previous.taken_at)
.as_secs_f64()
.max(f64::EPSILON);
VoiceRates {
packets_received_per_sec: self
.packets_received
.saturating_sub(previous.packets_received)
as f64
/ secs,
bytes_received_per_sec: self.bytes_received.saturating_sub(previous.bytes_received)
as f64
/ secs,
packets_sent_per_sec: self.packets_sent.saturating_sub(previous.packets_sent) as f64
/ secs,
bytes_sent_per_sec: self.bytes_sent.saturating_sub(previous.bytes_sent) as f64 / secs,
packets_dropped_per_sec: self
.packets_dropped
.saturating_sub(previous.packets_dropped)
as f64
/ secs,
}
}
}
impl MetricsSnapshot for VoiceMetricsSnapshot {
fn taken_at(&self) -> Instant {
self.taken_at
}
}
/// Taux moyens par seconde calculés entre deux [`VoiceMetricsSnapshot`].
#[derive(Debug, Clone, Copy)]
pub struct VoiceRates {
pub packets_received_per_sec: f64,
pub bytes_received_per_sec: f64,
pub packets_sent_per_sec: f64,
pub bytes_sent_per_sec: f64,
pub packets_dropped_per_sec: f64,
}
pub fn spawn_reporter(metrics: Arc<VoiceMetrics>, interval: Duration) {
tokio::spawn(async move {
let mut ticker = tokio::time::interval(interval);
ticker.tick().await;
let mut prev_snapshot = metrics.snapshot();
loop {
ticker.tick().await;
let current = metrics.snapshot();
let rates = current.rates_since(&prev_snapshot);
tracing::info!(
pkts_rx = current.packets_received,
bytes_rx = current.bytes_received,
pkts_tx = current.packets_sent,
bytes_tx = current.bytes_sent,
pkts_dropped = current.packets_dropped,
send_errors = current.send_errors,
recv_errors = current.recv_errors,
pkts_rx_s = format!("{:.1}", rates.packets_received_per_sec),
bytes_rx_s = format!("{:.0}", rates.bytes_received_per_sec),
pkts_tx_s = format!("{:.1}", rates.packets_sent_per_sec),
bytes_tx_s = format!("{:.0}", rates.bytes_sent_per_sec),
pkts_dropped_s = format!("{:.1}", rates.packets_dropped_per_sec),
"Voice metrics"
);
prev_snapshot = current;
}
});
}
+367
View File
@@ -0,0 +1,367 @@
mod client;
mod messages;
mod metrics;
mod stun_diagnostic;
pub mod ws_entrypoint;
use crate::config::NetworkConfig;
use crate::domain::events::voice_presence::VoicePresenceEvent;
use crate::repositories::Repositories;
use crate::services::Services;
use event_bus::EventBus;
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
// 4. Serveur crée sa PeerConnection
// 5. Serveur applique l'offer
// 6. Serveur crée une SDP answer
// 7. Serveur renvoie l'answer via WebSocket
// 8. Client et serveur échangent les candidats ICE
// 9. ICE sélectionne un chemin réseau
// 10. La connexion WebRTC devient active
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: Mutex<HashMap<uuid::Uuid, VoiceRoom>>,
event_bus: Option<Arc<EventBus>>,
}
#[derive(Clone)]
pub struct RTCManager {
pub config: RtcConfiguration,
pub repositories: Arc<Repositories>,
pub services: Arc<Services>,
pub event_bus: Arc<EventBus>,
pub(crate) rooms: Arc<VoiceRoomManager>,
}
impl fmt::Debug for RTCManager {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("VoiceService")
.field("config", &self.config)
// .field("metrics", &self.metrics)
.finish()
}
}
impl VoiceRoom {
pub fn new() -> Self {
Self { participants: HashMap::new(), sources: HashMap::new(), users: HashMap::new(), server_id: None }
}
}
impl VoiceRoomManager {
pub fn new() -> Self {
Self {
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 {
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 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::with_event_bus(event_bus.clone()));
Self {
config,
repositories,
services,
event_bus,
rooms,
}
}
pub fn new_peer_connection(&self) -> Arc<PeerConnection> {
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));
}
}
+65
View File
@@ -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());
}
}
+112
View File
@@ -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>
+68
View File
@@ -0,0 +1,68 @@
// This is the first point when WebRTC ask for a connection
use super::client::RTCClient;
use crate::core::AppState;
use crate::models::{channel, user};
use axum::extract::ws::{Message, WebSocket};
use futures_util::{SinkExt, StreamExt};
use tokio::sync::mpsc;
// 1. Le frontend ouvre /rtc/{channel_id}
// 2. Axum extrait channel_id
// 3. CurrentUser identifie l'utilisateur
// 4. Le serveur vérifie le canal et les permissions
// 5. Le serveur accepte le WebSocket
// 6. Le client envoie son SDP Offer
// 7. Le serveur crée la PeerConnection
// 8. Le serveur applique l'Offer
// 9. Le serveur crée une SDP Answer
// 10. Le serveur renvoie l'Answer
// 11. Les candidats ICE sont échangés
// 12. À la déconnexion, la PeerConnection est fermée
pub async fn ws_entrypoint_handler(
socket: WebSocket,
state: AppState,
channel: channel::Model,
user: user::Model,
) {
let (mut sender, mut receiver) = socket.split();
let (tx, mut rx) = mpsc::unbounded_channel::<Message>();
let peer_connection = state.rtc.new_peer_connection();
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 {
while let Some(message) = rx.recv().await {
if sender.send(message).await.is_err() {
break;
}
}
});
// Task pour recevoir les messages du frontend afin de les transmettre à RTCClient
let client_clone = rtc_client.clone();
let mut recv_task = tokio::spawn(async move {
while let Some(Ok(message)) = receiver.next().await {
if !client_clone.ws_on_message(message).await {
break;
}
}
});
tokio::select! {
_ = &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();
}
+10 -3
View File
@@ -1,3 +1,6 @@
use crate::domain::events::category::{
CategoryCreatedEvent, CategoryDeletedEvent, CategoryUpdatedEvent,
};
use crate::models::category; use crate::models::category;
use crate::models::server_item_order::OrderedResourceType; use crate::models::server_item_order::OrderedResourceType;
use crate::services::ServicesContext; use crate::services::ServicesContext;
@@ -49,7 +52,9 @@ impl CategoryService {
}) })
.await?; .await?;
event_bus.emit("category_created", cat.clone()); event_bus.emit(CategoryCreatedEvent {
category: cat.clone(),
});
Ok(cat) Ok(cat)
} }
@@ -76,7 +81,9 @@ impl CategoryService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("category_updated", cat.clone()); event_bus.emit(CategoryUpdatedEvent {
category: cat.clone(),
});
Ok(cat) Ok(cat)
} }
@@ -107,7 +114,7 @@ impl CategoryService {
txn.commit().await?; txn.commit().await?;
if deleted { if deleted {
event_bus.emit("category_deleted", existing); event_bus.emit(CategoryDeletedEvent { category: existing });
} }
Ok(deleted) Ok(deleted)
+56 -27
View File
@@ -2,11 +2,16 @@ use crate::domain::dto::channel::{CreateChannelRequest, UpdateChannelRequest};
use crate::domain::events::channel::{ use crate::domain::events::channel::{
ChannelCreatedEvent, ChannelDeletedEvent, ChannelUpdatedEvent, ChannelCreatedEvent, ChannelDeletedEvent, ChannelUpdatedEvent,
}; };
use crate::domain::events::channel_permission::{
ChannelRolePermissionUpdatedEvent, ChannelUserPermissionCreatedEvent,
ChannelUserPermissionDeletedEvent,
};
use crate::models::server_item_order::OrderedResourceType; use crate::models::server_item_order::OrderedResourceType;
use crate::models::{channel, role}; use crate::models::{channel, role};
use crate::permissions::PermissionSet; use crate::permissions::PermissionSet;
use crate::services::ServicesContext; use crate::services::ServicesContext;
use crate::services::permission::PermissionService; use crate::services::permission::PermissionService;
use anyhow::Context;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set, TransactionTrait};
use std::sync::Arc; use std::sync::Arc;
use uuid::Uuid; use uuid::Uuid;
@@ -91,12 +96,9 @@ impl ChannelService {
.await?; .await?;
// Post-commit event emission // Post-commit event emission
event_bus.emit( event_bus.emit(ChannelCreatedEvent {
"channel_created",
ChannelCreatedEvent {
channel: channel.clone(), channel: channel.clone(),
}, });
);
Ok(channel) Ok(channel)
} }
@@ -141,13 +143,10 @@ impl ChannelService {
txn.commit().await?; txn.commit().await?;
event_bus.emit( event_bus.emit(ChannelUpdatedEvent {
"channel_updated",
ChannelUpdatedEvent {
previous, previous,
channel: channel.clone(), channel: channel.clone(),
}, });
);
Ok(channel) Ok(channel)
} }
@@ -156,12 +155,7 @@ impl ChannelService {
let db = &self.service_context.repositories.server.context.db; let db = &self.service_context.repositories.server.context.db;
let event_bus = &self.service_context.event_bus; let event_bus = &self.service_context.event_bus;
let txn = db.begin().await?; let txn = db.begin().await.context("begin channel deletion transaction")?;
let existing = channel::Entity::find_by_id(id)
.one(&txn)
.await?
.ok_or_else(|| anyhow::anyhow!("Channel not found"))?;
self.service_context self.service_context
.services .services
@@ -169,16 +163,26 @@ impl ChannelService {
.expect("services initialized") .expect("services initialized")
.server_order .server_order
.remove(&txn, id, OrderedResourceType::Channel) .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; let deleted = res.rows_affected > 0;
txn.commit().await?; txn.commit().await.context("commit channel deletion")?;
if deleted { if deleted {
event_bus.emit("channel_deleted", ChannelDeletedEvent { channel: existing }); event_bus.emit(ChannelDeletedEvent { channel: existing });
} }
Ok(deleted) Ok(deleted)
@@ -214,12 +218,15 @@ impl ChannelService {
.exec(&txn) .exec(&txn)
.await?; .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?; txn.commit().await?;
event_bus.emit( event_bus.emit(ChannelUserPermissionCreatedEvent { permission });
"channel_user_permission_created",
(channel_id, user_id, permissions),
);
Ok(()) Ok(())
} }
@@ -234,6 +241,11 @@ impl ChannelService {
let txn = db.begin().await?; 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() 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::ChannelId.eq(channel_id))
.filter(crate::models::channel_user_permission::Column::UserId.eq(user_id)) .filter(crate::models::channel_user_permission::Column::UserId.eq(user_id))
@@ -242,7 +254,9 @@ impl ChannelService {
txn.commit().await?; 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(()) Ok(())
} }
@@ -285,9 +299,19 @@ impl ChannelService {
.exec(&txn) .exec(&txn)
.await?; .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?; 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(()) Ok(())
} }
@@ -318,7 +342,12 @@ impl ChannelService {
txn.commit().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: None,
});
Ok(()) Ok(())
} }
+8 -44
View File
@@ -3,7 +3,6 @@ use crate::domain::events::message::{
}; };
use crate::models::{attachment, channel, message}; use crate::models::{attachment, channel, message};
use crate::services::ServicesContext; use crate::services::ServicesContext;
use event_bus::Scope;
use sea_orm::{ use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait, ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QuerySelect, Set, TransactionTrait,
}; };
@@ -41,9 +40,6 @@ impl MessageService {
txn.commit().await?; 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) let server_id: Option<Uuid> = channel::Entity::find_by_id(msg.channel_id)
.select_only() .select_only()
.column(channel::Column::ServerId) .column(channel::Column::ServerId)
@@ -52,19 +48,11 @@ impl MessageService {
.await? .await?
.flatten(); .flatten();
if let Some(server_id) = server_id { event_bus.emit(MessageCreatedEvent {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_created",
scopes,
MessageCreatedEvent {
server_id, server_id,
channel_id: msg.channel_id, channel_id: msg.channel_id,
message: msg.clone(), message: msg.clone(),
}, });
);
Ok(msg) Ok(msg)
} }
@@ -119,19 +107,11 @@ impl MessageService {
.one(db) .one(db)
.await? .await?
.flatten(); .flatten();
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)]; event_bus.emit(MessageCreatedEvent {
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_created",
scopes,
MessageCreatedEvent {
server_id, server_id,
channel_id: msg.channel_id, channel_id: msg.channel_id,
message: msg.clone(), message: msg.clone(),
}, });
);
Ok(msg) Ok(msg)
} }
@@ -165,19 +145,11 @@ impl MessageService {
.await? .await?
.flatten(); .flatten();
let mut scopes = vec![Scope::uuid("channel", msg.channel_id)]; event_bus.emit(MessageUpdatedEvent {
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_updated",
scopes,
MessageUpdatedEvent {
server_id, server_id,
channel_id: msg.channel_id, channel_id: msg.channel_id,
message: msg.clone(), message: msg.clone(),
}, });
);
Ok(msg) Ok(msg)
} }
@@ -208,19 +180,11 @@ impl MessageService {
txn.commit().await?; txn.commit().await?;
if deleted { if deleted {
let mut scopes = vec![Scope::uuid("channel", existing.channel_id)]; event_bus.emit(MessageDeletedEvent {
if let Some(server_id) = server_id {
scopes.push(Scope::uuid("server", server_id));
}
event_bus.emit_scoped(
"message_deleted",
scopes,
MessageDeletedEvent {
server_id, server_id,
channel_id: existing.channel_id, channel_id: existing.channel_id,
message: existing, message: existing,
}, });
);
} }
Ok(deleted) Ok(deleted)
+4 -21
View File
@@ -3,7 +3,6 @@ use crate::domain::events::message::{MessageReactionAddedEvent, MessageReactionR
use crate::http::error::HTTPError; use crate::http::error::HTTPError;
use crate::models::{emoji, message_reaction}; use crate::models::{emoji, message_reaction};
use crate::services::ServicesContext; use crate::services::ServicesContext;
use event_bus::Scope;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, Set};
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Arc; use std::sync::Arc;
@@ -117,19 +116,11 @@ impl MessageReactionService {
}) })
.await?; .await?;
let mut scopes = vec![Scope::uuid("channel", message.channel_id)]; self.context.event_bus.emit(MessageReactionAddedEvent {
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, server_id,
channel_id: message.channel_id, channel_id: message.channel_id,
reaction: reaction.clone(), reaction: reaction.clone(),
}, });
);
Ok((reaction, true)) Ok((reaction, true))
} }
@@ -151,19 +142,11 @@ impl MessageReactionService {
.await? .await?
.ok_or(HTTPError::NotFound)?; .ok_or(HTTPError::NotFound)?;
let mut scopes = vec![Scope::uuid("channel", message.channel_id)]; self.context.event_bus.emit(MessageReactionRemovedEvent {
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, server_id,
channel_id: message.channel_id, channel_id: message.channel_id,
reaction: reaction.clone(), reaction: reaction.clone(),
}, });
);
Ok(reaction) Ok(reaction)
} }
+69 -46
View File
@@ -1,6 +1,16 @@
use crate::domain::events::channel::{ChannelCreatedEvent, ChannelDeletedEvent}; 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::domain::events::server_tree::ServerTreeInvalidatedEvent;
use crate::models::server;
use crate::repositories::Repositories; use crate::repositories::Repositories;
use crate::services::ServicesContext; use crate::services::ServicesContext;
use event_bus::EventBus; use event_bus::EventBus;
@@ -60,13 +70,10 @@ pub struct PermissionSyncService {
impl PermissionSyncService { impl PermissionSyncService {
fn invalidate_tree(event_bus: &Arc<EventBus>, server_id: Uuid, user_ids: Option<Vec<Uuid>>) { fn invalidate_tree(event_bus: &Arc<EventBus>, server_id: Uuid, user_ids: Option<Vec<Uuid>>) {
event_bus.emit( event_bus.emit(ServerTreeInvalidatedEvent {
"server_tree_invalidated",
ServerTreeInvalidatedEvent {
server_id, server_id,
user_ids, user_ids,
}, });
);
} }
pub fn new(service_context: Arc<ServicesContext>) -> Self { pub fn new(service_context: Arc<ServicesContext>) -> Self {
@@ -83,18 +90,18 @@ impl PermissionSyncService {
// Événements Serveur & Membres Serveur // Événements Serveur & Membres Serveur
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ServerCreatedEvent, _>(
"server_created",
repositories.clone(), repositories.clone(),
move |repositories, server: server::Model| async move { move |repositories, event| async move {
Self::sync_server(repositories, server.id).await; Self::sync_server(repositories, event.server.id).await;
}, },
); );
event_bus.on_async_with( event_bus.on_async_with::<ServerUserCreatedEvent, _>(
"server_user_created",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
Self::sync_user(repositories, user_id, server_id).await; Self::sync_user(repositories, user_id, server_id).await;
@@ -104,10 +111,11 @@ impl PermissionSyncService {
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ServerUserDeletedEvent, _>(
"server_user_deleted",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
Self::sync_user(repositories, user_id, server_id).await; Self::sync_user(repositories, user_id, server_id).await;
@@ -121,10 +129,11 @@ impl PermissionSyncService {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<RoleUserCreatedEvent, _>(
"role_user_created",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
Self::sync_user(repositories, user_id, server_id).await; Self::sync_user(repositories, user_id, server_id).await;
@@ -134,10 +143,11 @@ impl PermissionSyncService {
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<RoleUserDeletedEvent, _>(
"role_user_deleted",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
Self::sync_user(repositories, user_id, server_id).await; Self::sync_user(repositories, user_id, server_id).await;
@@ -151,10 +161,11 @@ impl PermissionSyncService {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ServerRolePermissionUpdatedEvent, _>(
"server_role_permission_updated",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
Self::sync_role_members(repositories, role_id, server_id).await; Self::sync_role_members(repositories, role_id, server_id).await;
@@ -164,10 +175,11 @@ impl PermissionSyncService {
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ServerUserPermissionUpdatedEvent, _>(
"server_user_permission_updated",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
Self::sync_user(repositories, user_id, server_id).await; Self::sync_user(repositories, user_id, server_id).await;
@@ -181,10 +193,9 @@ impl PermissionSyncService {
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ChannelCreatedEvent, _>(
"channel_created",
repositories.clone(), repositories.clone(),
move |repositories, event: ChannelCreatedEvent| { move |repositories, event| {
let notify = notify.clone(); let notify = notify.clone();
async move { async move {
if let Some(server_id) = event.channel.server_id { if let Some(server_id) = event.channel.server_id {
@@ -196,10 +207,9 @@ impl PermissionSyncService {
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ChannelDeletedEvent, _>(
"channel_deleted",
repositories.clone(), repositories.clone(),
move |repositories, event: ChannelDeletedEvent| { move |repositories, event| {
let notify = notify.clone(); let notify = notify.clone();
async move { async move {
if let Some(server_id) = event.channel.server_id { if let Some(server_id) = event.channel.server_id {
@@ -211,10 +221,11 @@ impl PermissionSyncService {
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ChannelRolePermissionUpdatedEvent, _>(
"channel_role_permission_updated",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
Self::sync_role_members(repositories, role_id, server_id).await; Self::sync_role_members(repositories, role_id, server_id).await;
@@ -224,23 +235,34 @@ impl PermissionSyncService {
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ChannelUserPermissionUpdatedEvent, _>(
"channel_user_permission_updated",
repositories.clone(), repositories.clone(),
move |repositories, (server_id, user_id): (Uuid, Uuid)| { move |repositories, event| {
let user_id = event.user_id;
let notify = notify.clone(); let notify = notify.clone();
async move { async move {
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::sync_user(repositories, user_id, server_id).await;
Self::invalidate_tree(&notify, server_id, Some(vec![user_id])); Self::invalidate_tree(&notify, server_id, Some(vec![user_id]));
} }
}
}
}, },
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ChannelUserPermissionCreatedEvent, _>(
"channel_user_permission_created",
repositories.clone(), 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(); let notify = notify.clone();
async move { async move {
if let Some(channel) = repositories if let Some(channel) = repositories
@@ -260,10 +282,11 @@ impl PermissionSyncService {
); );
let notify = event_bus.clone(); let notify = event_bus.clone();
event_bus.on_async_with( event_bus.on_async_with::<ChannelUserPermissionDeletedEvent, _>(
"channel_user_permission_deleted",
repositories, 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(); let notify = notify.clone();
async move { async move {
if let Some(channel) = repositories if let Some(channel) = repositories
+17 -12
View File
@@ -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::models::{channel, channel_user, computed_permission::PermissionScopeType};
use crate::permissions::ChannelPermission; use crate::permissions::ChannelPermission;
use crate::repositories::Repositories; use crate::repositories::Repositories;
@@ -204,10 +209,10 @@ impl RealtimeRegistry {
event_bus: Arc<EventBus>, event_bus: Arc<EventBus>,
) { ) {
let registry = Arc::clone(self); let registry = Arc::clone(self);
event_bus.on_async_with( event_bus.on_async_with::<ChannelUserPermissionUpdatedEvent, _>(
"channel_user_permission_updated",
repositories.clone(), 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(&registry); let registry = Arc::clone(&registry);
async move { async move {
if let Err(error) = registry.refresh_user(&repositories, user_id).await { if let Err(error) = registry.refresh_user(&repositories, user_id).await {
@@ -218,10 +223,10 @@ impl RealtimeRegistry {
); );
let registry = Arc::clone(self); let registry = Arc::clone(self);
event_bus.on_async_with( event_bus.on_async_with::<ChannelUserPermissionCreatedEvent, _>(
"channel_user_permission_created",
repositories.clone(), 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(&registry); let registry = Arc::clone(&registry);
async move { async move {
if let Err(error) = registry.refresh_user(&repositories, user_id).await { if let Err(error) = registry.refresh_user(&repositories, user_id).await {
@@ -232,10 +237,10 @@ impl RealtimeRegistry {
); );
let registry = Arc::clone(self); let registry = Arc::clone(self);
event_bus.on_async_with( event_bus.on_async_with::<ChannelUserPermissionDeletedEvent, _>(
"channel_user_permission_deleted",
repositories.clone(), repositories.clone(),
move |repositories, (_channel_id, user_id): (Uuid, Uuid)| { move |repositories, event| {
let user_id = event.permission.user_id;
let registry = Arc::clone(&registry); let registry = Arc::clone(&registry);
async move { async move {
if let Err(error) = registry.refresh_user(&repositories, user_id).await { if let Err(error) = registry.refresh_user(&repositories, user_id).await {
@@ -247,10 +252,10 @@ impl RealtimeRegistry {
let registry = Arc::clone(self); let registry = Arc::clone(self);
let repositories = repositories.clone(); let repositories = repositories.clone();
event_bus.on_async_with( event_bus.on_async_with::<ServerUserPermissionUpdatedEvent, _>(
"server_user_permission_updated",
repositories, repositories,
move |repositories, (_server_id, user_id): (Uuid, Uuid)| { move |repositories, event| {
let user_id = event.user_id;
let registry = Arc::clone(&registry); let registry = Arc::clone(&registry);
async move { async move {
if let Err(error) = registry.refresh_user(&repositories, user_id).await { if let Err(error) = registry.refresh_user(&repositories, user_id).await {
+26 -12
View File
@@ -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 crate::services::ServicesContext;
use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait}; use sea_orm::{ActiveModelTrait, EntityTrait, TransactionTrait};
use std::sync::Arc; use std::sync::Arc;
@@ -27,7 +31,7 @@ impl RoleService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("role_created", role.clone()); event_bus.emit(RoleCreatedEvent { role: role.clone() });
Ok(role) Ok(role)
} }
@@ -45,7 +49,7 @@ impl RoleService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("role_updated", role.clone()); event_bus.emit(RoleUpdatedEvent { role: role.clone() });
Ok(role) Ok(role)
} }
@@ -56,14 +60,15 @@ impl RoleService {
let txn = db.begin().await?; 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 res = role::Entity::delete_by_id(id).exec(&txn).await?;
let deleted = res.rows_affected > 0; let deleted = res.rows_affected > 0;
txn.commit().await?; txn.commit().await?;
if deleted { if let Some(role) = existing.filter(|_| deleted) {
event_bus.emit("role_deleted", id); event_bus.emit(RoleDeletedEvent { role });
} }
Ok(deleted) Ok(deleted)
@@ -82,9 +87,14 @@ impl RoleService {
.add_member(role_id, user_id) .add_member(role_id, user_id)
.await?; .await?;
if added { if added {
self.service_context let role_user = role_user::Entity::find_by_id((role_id, user_id))
.event_bus .one(&self.service_context.repositories.server.context.db)
.emit("role_user_created", (role_id, user_id, server_id)); .await?
.ok_or_else(|| anyhow::anyhow!("Role membership not found"))?;
self.service_context.event_bus.emit(RoleUserCreatedEvent {
role_user,
server_id,
});
} }
Ok(added) Ok(added)
} }
@@ -95,16 +105,20 @@ impl RoleService {
user_id: Uuid, user_id: Uuid,
server_id: Uuid, server_id: Uuid,
) -> Result<bool, anyhow::Error> { ) -> 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 let removed = self
.service_context .service_context
.repositories .repositories
.role .role
.remove_member(role_id, user_id) .remove_member(role_id, user_id)
.await?; .await?;
if removed { if let Some(role_user) = existing.filter(|_| removed) {
self.service_context self.service_context.event_bus.emit(RoleUserDeletedEvent {
.event_bus role_user,
.emit("role_user_deleted", (role_id, user_id, server_id)); server_id,
});
} }
Ok(removed) Ok(removed)
} }
+29 -9
View File
@@ -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::models::{role, role_user, server, server_user};
use crate::services::ServicesContext; use crate::services::ServicesContext;
use sea_orm::{ use sea_orm::{
@@ -63,7 +67,9 @@ impl ServerService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("server_created", srv.clone()); event_bus.emit(ServerCreatedEvent {
server: srv.clone(),
});
Ok(srv) Ok(srv)
} }
@@ -97,7 +103,7 @@ impl ServerService {
return Ok(srv); return Ok(srv);
} }
server_user::ActiveModel { let membership = server_user::ActiveModel {
server_id: Set(server_id), server_id: Set(server_id),
user_id: Set(user_id), user_id: Set(user_id),
..Default::default() ..Default::default()
@@ -119,7 +125,9 @@ impl ServerService {
.await?; .await?;
txn.commit().await?; txn.commit().await?;
event_bus.emit("server_user_created", (server_id, user_id)); event_bus.emit(ServerUserCreatedEvent {
server_user: membership,
});
self.service_context self.service_context
.repositories .repositories
.read_state .read_state
@@ -152,7 +160,9 @@ impl ServerService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("server_updated", srv.clone()); event_bus.emit(ServerUpdatedEvent {
server: srv.clone(),
});
Ok(srv) Ok(srv)
} }
@@ -182,7 +192,10 @@ impl ServerService {
txn.commit().await?; txn.commit().await?;
if deleted { if deleted {
event_bus.emit("server_deleted", (existing, audience)); event_bus.emit(ServerDeletedEvent {
server: existing,
audience,
});
} }
Ok(deleted) Ok(deleted)
@@ -194,7 +207,7 @@ impl ServerService {
let txn = db.begin().await?; let txn = db.begin().await?;
crate::models::server_user::ActiveModel { let membership = crate::models::server_user::ActiveModel {
server_id: Set(server_id), server_id: Set(server_id),
user_id: Set(user_id), user_id: Set(user_id),
..Default::default() ..Default::default()
@@ -214,7 +227,9 @@ impl ServerService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("server_user_created", (server_id, user_id)); event_bus.emit(ServerUserCreatedEvent {
server_user: membership,
});
Ok(true) Ok(true)
} }
@@ -225,6 +240,11 @@ impl ServerService {
let txn = db.begin().await?; 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() 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::ServerId.eq(server_id))
.filter(crate::models::server_user::Column::UserId.eq(user_id)) .filter(crate::models::server_user::Column::UserId.eq(user_id))
@@ -235,8 +255,8 @@ impl ServerService {
txn.commit().await?; txn.commit().await?;
if deleted { if let Some(server_user) = membership.filter(|_| deleted) {
event_bus.emit("server_user_deleted", (server_id, user_id)); event_bus.emit(ServerUserDeletedEvent { server_user });
} }
Ok(deleted) Ok(deleted)
+9 -5
View File
@@ -1,4 +1,7 @@
use crate::auth::password; use crate::auth::password;
use crate::domain::events::user::{
UserChangedEvent, UserCreatedEvent, UserDeletedEvent, UserUpdatedEvent,
};
use crate::models::{role, user}; use crate::models::{role, user};
use crate::services::ServicesContext; use crate::services::ServicesContext;
use sea_orm::{ use sea_orm::{
@@ -30,7 +33,7 @@ impl UserService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("user_created", usr.clone()); event_bus.emit(UserCreatedEvent { user: usr.clone() });
Ok(usr) Ok(usr)
} }
@@ -48,7 +51,7 @@ impl UserService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("user_updated", usr.clone()); event_bus.emit(UserUpdatedEvent { user: usr.clone() });
Ok(usr) Ok(usr)
} }
@@ -79,7 +82,7 @@ impl UserService {
txn.commit().await?; txn.commit().await?;
event_bus.emit("user_changed", usr); event_bus.emit(UserChangedEvent { user: usr });
Ok(()) Ok(())
} }
@@ -90,14 +93,15 @@ impl UserService {
let txn = db.begin().await?; 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 res = user::Entity::delete_by_id(id).exec(&txn).await?;
let deleted = res.rows_affected > 0; let deleted = res.rows_affected > 0;
txn.commit().await?; txn.commit().await?;
if deleted { if let Some(user) = existing.filter(|_| deleted) {
event_bus.emit("user_deleted", id); event_bus.emit(UserDeletedEvent { user });
} }
Ok(deleted) Ok(deleted)
+2
View File
@@ -0,0 +1,2 @@
Recréer ce module de 0, afin de le comprendre à 100% (dans le dossier RTC)

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