87 lines
3.0 KiB
Rust
87 lines
3.0 KiB
Rust
|
|
use leptos::prelude::*;
|
||
|
|
use serde::{Deserialize, Serialize};
|
||
|
|
|
||
|
|
/// Call signaling for a room, kept entirely separate from `chat::ChatMessage`
|
||
|
|
/// - deliberately a different NATS subject namespace (`call.room.<room>`,
|
||
|
|
/// not `chat.room.<room>`) so `server::store`'s JetStream/Postgres archive
|
||
|
|
/// (scoped to `chat.room.*` only) never sees it. Ephemeral SDP/ICE has no
|
||
|
|
/// business being durably stored.
|
||
|
|
pub fn call_subject(room: &str) -> String {
|
||
|
|
format!("call.room.{room}")
|
||
|
|
}
|
||
|
|
|
||
|
|
/// `from`/`to` are peer ids - currently just the signed-in username (same
|
||
|
|
/// identity chat messages use). `to: None` is a room-wide broadcast (only
|
||
|
|
/// `Join`/`Leave` use this); everything else is directed at one peer, with
|
||
|
|
/// every other browser in the room ignoring it client-side. Mesh calls at
|
||
|
|
/// this scale (~4 people) don't need per-peer NATS subjects - broadcast +
|
||
|
|
/// client-side filter is the simplest thing that works.
|
||
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
|
|
pub struct CallSignal {
|
||
|
|
pub room: String,
|
||
|
|
pub from: String,
|
||
|
|
pub to: Option<String>,
|
||
|
|
pub kind: CallSignalKind,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||
|
|
#[serde(tag = "kind", content = "data")]
|
||
|
|
pub enum CallSignalKind {
|
||
|
|
/// Announces presence to the room; existing participants respond by
|
||
|
|
/// initiating an offer to the new peer.
|
||
|
|
Join,
|
||
|
|
Leave,
|
||
|
|
Offer(String),
|
||
|
|
Answer(String),
|
||
|
|
/// A single trickled ICE candidate, JSON-encoded
|
||
|
|
/// (`RTCIceCandidateInit`, produced client-side).
|
||
|
|
IceCandidate(String),
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Publishes a call-signaling message to the room's signaling subject.
|
||
|
|
/// Requires a signed-in, room-authorized session - same two checks as
|
||
|
|
/// `chat::send_message`, and deliberately not shared via a helper since
|
||
|
|
/// the existing chat functions already establish that each enforcement
|
||
|
|
/// point re-does this small check inline rather than factoring it out.
|
||
|
|
#[server]
|
||
|
|
pub async fn send_signal(
|
||
|
|
room: String,
|
||
|
|
to: Option<String>,
|
||
|
|
kind: CallSignalKind,
|
||
|
|
) -> Result<(), ServerFnError> {
|
||
|
|
use crate::auth::{User, SESSION_USER_KEY};
|
||
|
|
use crate::chat::is_authorized_for_room;
|
||
|
|
use crate::server::AppState;
|
||
|
|
|
||
|
|
if !crate::chat::is_valid_room(&room) {
|
||
|
|
return Err(ServerFnError::new("unknown room"));
|
||
|
|
}
|
||
|
|
|
||
|
|
let session: tower_sessions::Session = leptos_axum::extract().await?;
|
||
|
|
let Some(user) = session
|
||
|
|
.get::<User>(SESSION_USER_KEY)
|
||
|
|
.await
|
||
|
|
.map_err(|e| ServerFnError::new(e.to_string()))?
|
||
|
|
else {
|
||
|
|
return Err(ServerFnError::new("not signed in"));
|
||
|
|
};
|
||
|
|
if !is_authorized_for_room(&user, &room) {
|
||
|
|
return Err(ServerFnError::new("not authorized for this room"));
|
||
|
|
}
|
||
|
|
|
||
|
|
let state = expect_context::<AppState>();
|
||
|
|
let signal = CallSignal {
|
||
|
|
room: room.clone(),
|
||
|
|
from: user.username,
|
||
|
|
to,
|
||
|
|
kind,
|
||
|
|
};
|
||
|
|
let payload = serde_json::to_vec(&signal).map_err(|e| ServerFnError::new(e.to_string()))?;
|
||
|
|
state
|
||
|
|
.nats
|
||
|
|
.publish(call_subject(&room), payload.into())
|
||
|
|
.await
|
||
|
|
.map_err(|e| ServerFnError::new(format!("nats publish failed: {e}")))?;
|
||
|
|
Ok(())
|
||
|
|
}
|