use std::collections::HashMap;
use std::future::Future;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::{Arc, RwLock};
use chrono::{DateTime, Duration, Utc};
use serde::{Serialize, Serializer};
use thiserror::Error;
use uuid::Uuid;
use autumn_web::reexports::axum::{
Json, Router,
extract::{ConnectInfo, FromRequestParts, MatchedPath, Path, State},
response::Response,
routing::{get, post},
};
use autumn_web::reexports::http::HeaderMap;
use autumn_web::reexports::http::request::Parts;
use autumn_web::route_listing::RouteInfo;
use autumn_web::security::{__check_throttle, KeyStrategy, RateLimitExempt, ThrottleSpec};
use autumn_web::{AppState, AutumnError, AutumnResult};
use crate::config::DEFAULT_ROOM_MAX_PARTICIPANTS;
use crate::transport::MediaUrls;
pub fn validate_room_segment(segment: &str) -> Result<(), RoomError> {
let ok = !segment.is_empty()
&& segment != "."
&& segment != ".."
&& segment
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-');
if ok {
Ok(())
} else {
Err(RoomError::InvalidSegment {
value: segment.to_owned(),
})
}
}
pub fn room_participant_path(
namespace: &str,
room_id: &str,
participant_id: &str,
) -> Result<String, RoomError> {
validate_room_segment(room_id)?;
validate_room_segment(participant_id)?;
if namespace.is_empty() {
Ok(format!("room/{room_id}/{participant_id}"))
} else {
validate_room_segment(namespace)?;
Ok(format!("room/{namespace}/{room_id}/{participant_id}"))
}
}
#[derive(Clone)]
pub struct SessionToken(String);
impl SessionToken {
#[must_use]
pub fn generate() -> Self {
Self(Uuid::new_v4().simple().to_string())
}
#[must_use]
pub fn expose(&self) -> &str {
&self.0
}
}
impl std::fmt::Debug for SessionToken {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_tuple("SessionToken")
.field(&"<redacted>")
.finish()
}
}
impl Serialize for SessionToken {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&self.0)
}
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum RoomError {
#[error("room not found")]
RoomNotFound,
#[error("room is full (max {max} participants)")]
RoomFull {
max: usize,
},
#[error("participant not found")]
ParticipantNotFound,
#[error("invalid session token")]
Unauthorized,
#[error("invalid room path segment `{value}`")]
InvalidSegment {
value: String,
},
#[error("max_participants {requested} is out of range (1..={cap})")]
InvalidMaxParticipants {
requested: usize,
cap: usize,
},
#[error("room registry is at capacity ({max} rooms); try again later")]
RegistryFull {
max: usize,
},
#[error("room store is temporarily unavailable")]
Store,
#[error("display_name is longer than {max} characters")]
DisplayNameTooLong {
max: usize,
},
}
impl RoomError {
#[must_use]
pub fn into_autumn(self) -> AutumnError {
match self {
Self::RoomNotFound | Self::ParticipantNotFound => {
AutumnError::not_found_msg(self.to_string())
}
Self::RoomFull { .. } => AutumnError::conflict_msg(self.to_string()),
Self::RegistryFull { .. } | Self::Store => {
AutumnError::service_unavailable_msg(self.to_string())
}
Self::Unauthorized => AutumnError::unauthorized_msg(self.to_string()),
Self::InvalidSegment { .. }
| Self::InvalidMaxParticipants { .. }
| Self::DisplayNameTooLong { .. } => AutumnError::bad_request_msg(self.to_string()),
}
}
}
#[derive(Debug)]
struct RoomParticipant {
id: String,
display_name: Option<String>,
joined_at: DateTime<Utc>,
token: SessionToken,
token_expires_at: DateTime<Utc>,
last_seen_at: DateTime<Utc>,
}
impl RoomParticipant {
fn verify_token(&self, candidate: &str) -> bool {
autumn_web::auth::constant_time_eq(candidate.as_bytes(), self.token.expose().as_bytes())
}
}
#[derive(Clone, Debug, Serialize)]
pub struct ParticipantView {
pub id: String,
pub display_name: Option<String>,
pub joined_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize)]
pub struct RoomSnapshot {
pub id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub namespace: String,
pub max_participants: usize,
pub created_at: DateTime<Utc>,
pub participants: Vec<ParticipantView>,
}
struct Room {
id: String,
namespace: String,
max_participants: usize,
created_at: DateTime<Utc>,
participants: HashMap<String, RoomParticipant>,
}
impl Room {
fn is_full(&self) -> bool {
self.participants.len() >= self.max_participants
}
fn snapshot(&self) -> RoomSnapshot {
let mut participants: Vec<ParticipantView> = self
.participants
.values()
.map(|participant| ParticipantView {
id: participant.id.clone(),
display_name: participant.display_name.clone(),
joined_at: participant.joined_at,
})
.collect();
participants.sort_by(|a, b| a.joined_at.cmp(&b.joined_at).then_with(|| a.id.cmp(&b.id)));
RoomSnapshot {
id: self.id.clone(),
namespace: self.namespace.clone(),
max_participants: self.max_participants,
created_at: self.created_at,
participants,
}
}
}
#[derive(Debug)]
pub struct JoinRecord {
pub participant_id: String,
pub token: SessionToken,
pub token_expires_at: DateTime<Utc>,
pub room: RoomSnapshot,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct ReapStats {
pub rooms_reaped: usize,
pub participants_reaped: usize,
}
pub type RoomStoreFuture<'a, T> = Pin<Box<dyn Future<Output = Result<T, RoomError>> + Send + 'a>>;
pub type ReapFuture<'a> = Pin<Box<dyn Future<Output = ReapStats> + Send + 'a>>;
#[allow(clippy::needless_lifetimes)]
pub trait RoomStore: Send + Sync {
fn create_room<'a>(
&'a self,
namespace: &'a str,
max_participants: usize,
) -> RoomStoreFuture<'a, RoomSnapshot>;
fn join_room<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
display_name: Option<String>,
token_ttl: Duration,
) -> RoomStoreFuture<'a, JoinRecord>;
fn leave_room<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
participant_id: &'a str,
token: &'a str,
) -> RoomStoreFuture<'a, ()>;
fn roster<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
auth_token: &'a str,
session_max: Duration,
) -> RoomStoreFuture<'a, RoomSnapshot>;
fn heartbeat<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
participant_id: &'a str,
token: &'a str,
token_ttl: Duration,
session_max: Duration,
) -> RoomStoreFuture<'a, DateTime<Utc>>;
fn reap_stale(&self, now: DateTime<Utc>, idle_ttl: Duration) -> ReapFuture<'_>;
}
#[must_use]
pub fn renewed_expiry(
joined_at: DateTime<Utc>,
now: DateTime<Utc>,
token_ttl: Duration,
session_max: Duration,
) -> Option<DateTime<Utc>> {
let session_end = joined_at + session_max;
(now < session_end).then(|| (now + token_ttl).min(session_end))
}
pub const MAX_ROOMS: usize = 10_000;
pub const MAX_DISPLAY_NAME_CHARS: usize = 64;
pub struct InMemoryRoomStore {
rooms: RwLock<HashMap<(String, String), Room>>,
hard_cap: usize,
max_rooms: usize,
}
impl InMemoryRoomStore {
#[must_use]
pub fn new(hard_cap: usize) -> Self {
Self {
rooms: RwLock::new(HashMap::new()),
hard_cap,
max_rooms: MAX_ROOMS,
}
}
#[must_use]
pub const fn with_max_rooms(mut self, max_rooms: usize) -> Self {
self.max_rooms = max_rooms;
self
}
}
impl Default for InMemoryRoomStore {
fn default() -> Self {
Self::new(DEFAULT_ROOM_MAX_PARTICIPANTS)
}
}
#[allow(clippy::needless_lifetimes)] impl RoomStore for InMemoryRoomStore {
fn create_room<'a>(
&'a self,
namespace: &'a str,
max_participants: usize,
) -> RoomStoreFuture<'a, RoomSnapshot> {
Box::pin(async move {
if !namespace.is_empty() {
validate_room_segment(namespace)?;
}
let ceiling = self.hard_cap.min(DEFAULT_ROOM_MAX_PARTICIPANTS);
if max_participants == 0 || max_participants > ceiling {
return Err(RoomError::InvalidMaxParticipants {
requested: max_participants,
cap: ceiling,
});
}
let id = Uuid::new_v4().to_string();
let room = Room {
id: id.clone(),
namespace: namespace.to_owned(),
max_participants,
created_at: Utc::now(),
participants: HashMap::new(),
};
let snapshot = room.snapshot();
let mut rooms = self.rooms.write().expect("room store lock poisoned");
if rooms.len() >= self.max_rooms {
return Err(RoomError::RegistryFull {
max: self.max_rooms,
});
}
rooms.insert((namespace.to_owned(), id), room);
drop(rooms);
Ok(snapshot)
})
}
fn join_room<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
display_name: Option<String>,
token_ttl: Duration,
) -> RoomStoreFuture<'a, JoinRecord> {
Box::pin(async move {
let mut rooms = self.rooms.write().expect("room store lock poisoned");
let room = rooms
.get_mut(&(namespace.to_owned(), room_id.to_owned()))
.ok_or(RoomError::RoomNotFound)?;
if room.is_full() {
return Err(RoomError::RoomFull {
max: room.max_participants,
});
}
let now = Utc::now();
let participant_id = Uuid::new_v4().to_string();
let token = SessionToken::generate();
let participant = RoomParticipant {
id: participant_id.clone(),
display_name,
joined_at: now,
token: token.clone(),
token_expires_at: now + token_ttl,
last_seen_at: now,
};
let token_expires_at = participant.token_expires_at;
room.participants
.insert(participant_id.clone(), participant);
let snapshot = room.snapshot();
drop(rooms);
Ok(JoinRecord {
participant_id,
token,
token_expires_at,
room: snapshot,
})
})
}
fn leave_room<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
participant_id: &'a str,
token: &'a str,
) -> RoomStoreFuture<'a, ()> {
Box::pin(async move {
let mut rooms = self.rooms.write().expect("room store lock poisoned");
let key = (namespace.to_owned(), room_id.to_owned());
let room = rooms.get_mut(&key).ok_or(RoomError::RoomNotFound)?;
let participant = room
.participants
.get(participant_id)
.ok_or(RoomError::ParticipantNotFound)?;
if !participant.verify_token(token) {
return Err(RoomError::Unauthorized);
}
room.participants.remove(participant_id);
let now_empty = room.participants.is_empty();
if now_empty {
rooms.remove(&key);
}
drop(rooms);
Ok(())
})
}
fn roster<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
auth_token: &'a str,
session_max: Duration,
) -> RoomStoreFuture<'a, RoomSnapshot> {
Box::pin(async move {
let mut rooms = self.rooms.write().expect("room store lock poisoned");
let room = rooms
.get_mut(&(namespace.to_owned(), room_id.to_owned()))
.ok_or(RoomError::RoomNotFound)?;
let now = Utc::now();
let member = room
.participants
.values_mut()
.find(|participant| participant.verify_token(auth_token))
.filter(|participant| now < participant.joined_at + session_max)
.ok_or(RoomError::RoomNotFound)?;
member.last_seen_at = now;
let snapshot = room.snapshot();
drop(rooms);
Ok(snapshot)
})
}
fn heartbeat<'a>(
&'a self,
namespace: &'a str,
room_id: &'a str,
participant_id: &'a str,
token: &'a str,
token_ttl: Duration,
session_max: Duration,
) -> RoomStoreFuture<'a, DateTime<Utc>> {
Box::pin(async move {
let mut rooms = self.rooms.write().expect("room store lock poisoned");
let room = rooms
.get_mut(&(namespace.to_owned(), room_id.to_owned()))
.ok_or(RoomError::RoomNotFound)?;
let participant = room
.participants
.get_mut(participant_id)
.ok_or(RoomError::RoomNotFound)?;
if !participant.verify_token(token) {
return Err(RoomError::RoomNotFound);
}
let now = Utc::now();
let renewed = renewed_expiry(participant.joined_at, now, token_ttl, session_max)
.ok_or(RoomError::RoomNotFound)?;
participant.last_seen_at = now;
participant.token_expires_at = renewed;
drop(rooms);
Ok(renewed)
})
}
fn reap_stale(&self, now: DateTime<Utc>, idle_ttl: Duration) -> ReapFuture<'_> {
Box::pin(async move {
let mut stats = ReapStats::default();
let mut rooms = self.rooms.write().expect("room store lock poisoned");
rooms.retain(|_key, room| {
let before = room.participants.len();
room.participants.retain(|_id, participant| {
now.signed_duration_since(participant.last_seen_at) < idle_ttl
});
let reaped = before - room.participants.len();
stats.participants_reaped += reaped;
if room.participants.is_empty() {
let drop_room =
reaped > 0 || now.signed_duration_since(room.created_at) >= idle_ttl;
if drop_room {
stats.rooms_reaped += 1;
return false;
}
}
true
});
stats
})
}
}
const ROOM_REAPER_INTERVAL_SECONDS: u64 = 60;
const ROOM_IDLE_TTL_SECONDS: u64 = 900;
const MAX_REAPER_TTL_SECONDS: i64 = 315_360_000;
fn env_positive_u64(key: &str, default: u64) -> u64 {
std::env::var(key)
.ok()
.and_then(|value| value.trim().parse::<u64>().ok())
.filter(|&value| value > 0)
.unwrap_or(default)
}
fn clamp_reaper_ttl(idle_ttl_secs: u64) -> Duration {
Duration::seconds(
i64::try_from(idle_ttl_secs)
.unwrap_or(MAX_REAPER_TTL_SECONDS)
.min(MAX_REAPER_TTL_SECONDS),
)
}
pub fn spawn_room_reaper_loop(store: Arc<dyn RoomStore>) {
let interval_secs = env_positive_u64(
"AUTUMN_MEDIA__ROOM_REAPER_INTERVAL_SECONDS",
ROOM_REAPER_INTERVAL_SECONDS,
);
let idle_ttl_secs =
env_positive_u64("AUTUMN_MEDIA__ROOM_IDLE_TTL_SECONDS", ROOM_IDLE_TTL_SECONDS);
let idle_ttl = clamp_reaper_ttl(idle_ttl_secs);
tokio::spawn(async move {
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(interval_secs));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
ticker.tick().await;
let stats = store.reap_stale(Utc::now(), idle_ttl).await;
if stats.rooms_reaped > 0 || stats.participants_reaped > 0 {
tracing::info!(
rooms_reaped = stats.rooms_reaped,
participants_reaped = stats.participants_reaped,
"media rooms: reaped stale participants / idle rooms"
);
}
}
});
}
#[derive(Clone, Debug, Serialize)]
pub struct PublishTarget {
pub path: String,
pub whip_url: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct SubscribeTarget {
pub participant_id: String,
pub path: String,
pub whep_url: String,
}
#[derive(Debug, Serialize)]
pub struct JoinResponse {
pub participant_id: String,
pub session_token: SessionToken,
pub token_expires_at: DateTime<Utc>,
pub publish: PublishTarget,
pub subscribe: Vec<SubscribeTarget>,
pub room: RoomSnapshot,
}
#[derive(Clone, Debug, Serialize)]
pub struct RosterEntry {
pub participant_id: String,
pub display_name: Option<String>,
pub joined_at: DateTime<Utc>,
pub path: String,
pub whep_url: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct RoomRoster {
pub id: String,
#[serde(skip_serializing_if = "String::is_empty")]
pub namespace: String,
pub max_participants: usize,
pub created_at: DateTime<Utc>,
pub participants: Vec<RosterEntry>,
}
#[derive(Clone)]
pub struct RoomService {
store: Arc<dyn RoomStore>,
urls: MediaUrls,
namespace: String,
token_ttl: Duration,
session_max: Duration,
max_participants: usize,
rate_limit_per_minute: u32,
}
impl RoomService {
#[must_use]
pub fn new(
store: Arc<dyn RoomStore>,
urls: MediaUrls,
namespace: impl Into<String>,
token_ttl: Duration,
max_participants: usize,
) -> Self {
Self {
store,
urls,
namespace: namespace.into(),
token_ttl,
session_max: Duration::seconds(i64::from(
crate::config::DEFAULT_ROOM_SESSION_MAX_SECONDS,
)),
max_participants,
rate_limit_per_minute: 0,
}
}
#[must_use]
pub const fn with_rate_limit_per_minute(mut self, per_minute: u32) -> Self {
self.rate_limit_per_minute = per_minute;
self
}
#[must_use]
pub const fn with_session_max(mut self, session_max: Duration) -> Self {
self.session_max = session_max;
self
}
#[must_use]
pub const fn rate_limit_per_minute(&self) -> u32 {
self.rate_limit_per_minute
}
#[must_use]
pub const fn session_max(&self) -> Duration {
self.session_max
}
#[must_use]
pub fn namespace(&self) -> &str {
&self.namespace
}
pub async fn create(&self) -> Result<RoomSnapshot, RoomError> {
self.store
.create_room(&self.namespace, self.max_participants)
.await
}
pub async fn join(
&self,
room_id: &str,
display_name: Option<String>,
) -> Result<JoinResponse, RoomError> {
if display_name
.as_deref()
.is_some_and(|name| name.chars().count() > MAX_DISPLAY_NAME_CHARS)
{
return Err(RoomError::DisplayNameTooLong {
max: MAX_DISPLAY_NAME_CHARS,
});
}
let record = self
.store
.join_room(
&self.namespace,
room_id,
display_name,
self.token_ttl.min(self.session_max),
)
.await?;
let publish_path = room_participant_path(&self.namespace, room_id, &record.participant_id)?;
let publish = PublishTarget {
whip_url: self.urls.whip_publish_url_for_path(&publish_path),
path: publish_path,
};
let mut subscribe = Vec::new();
for peer in &record.room.participants {
if peer.id == record.participant_id {
continue;
}
let peer_path = room_participant_path(&self.namespace, room_id, &peer.id)?;
subscribe.push(SubscribeTarget {
participant_id: peer.id.clone(),
whep_url: self.urls.whep_read_url(&peer_path),
path: peer_path,
});
}
Ok(JoinResponse {
participant_id: record.participant_id,
session_token: record.token,
token_expires_at: record.token_expires_at,
publish,
subscribe,
room: record.room,
})
}
pub async fn leave(
&self,
room_id: &str,
participant_id: &str,
token: &str,
) -> Result<(), RoomError> {
self.store
.leave_room(&self.namespace, room_id, participant_id, token)
.await
}
pub async fn heartbeat(
&self,
room_id: &str,
participant_id: &str,
token: &str,
) -> Result<HeartbeatResponse, RoomError> {
let token_expires_at = self
.store
.heartbeat(
&self.namespace,
room_id,
participant_id,
token,
self.token_ttl,
self.session_max,
)
.await?;
Ok(HeartbeatResponse {
alive: true,
token_expires_at,
})
}
pub async fn roster(&self, room_id: &str, auth_token: &str) -> Result<RoomRoster, RoomError> {
let snapshot = self
.store
.roster(&self.namespace, room_id, auth_token, self.session_max)
.await?;
let mut participants = Vec::with_capacity(snapshot.participants.len());
for participant in snapshot.participants {
let path = room_participant_path(&self.namespace, room_id, &participant.id)?;
participants.push(RosterEntry {
participant_id: participant.id,
display_name: participant.display_name,
joined_at: participant.joined_at,
whep_url: self.urls.whep_read_url(&path),
path,
});
}
Ok(RoomRoster {
id: snapshot.id,
namespace: snapshot.namespace,
max_participants: snapshot.max_participants,
created_at: snapshot.created_at,
participants,
})
}
}
#[derive(Debug, serde::Deserialize)]
pub struct JoinRequest {
#[serde(default)]
pub display_name: Option<String>,
}
#[derive(serde::Deserialize)]
pub struct LeaveRequest {
pub participant_id: String,
pub session_token: String,
}
impl std::fmt::Debug for LeaveRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("LeaveRequest")
.field("participant_id", &self.participant_id)
.field("session_token", &"<redacted>")
.finish()
}
}
#[derive(serde::Deserialize)]
pub struct HeartbeatRequest {
pub participant_id: String,
pub session_token: String,
}
impl std::fmt::Debug for HeartbeatRequest {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("HeartbeatRequest")
.field("participant_id", &self.participant_id)
.field("session_token", &"<redacted>")
.finish()
}
}
#[derive(Debug, Serialize)]
pub struct HeartbeatResponse {
pub alive: bool,
pub token_expires_at: DateTime<Utc>,
}
fn room_service(state: &AppState) -> AutumnResult<Arc<RoomService>> {
state.extension::<RoomService>().ok_or_else(|| {
AutumnError::internal_server_error_msg("RoomService extension is not installed")
})
}
const ROOM_RATE_ROUTE_ID: &str = "autumn_media_plugin::rooms";
struct RoomRateGate;
impl FromRequestParts<AppState> for RoomRateGate {
type Rejection = Response;
async fn from_request_parts(parts: &mut Parts, state: &AppState) -> Result<Self, Response> {
let per_minute = state
.extension::<RoomService>()
.map_or(0, |rooms| rooms.rate_limit_per_minute());
if per_minute == 0 {
return Ok(Self);
}
let matched_path = parts.extensions.get::<MatchedPath>().cloned();
let peer = parts
.extensions
.get::<ConnectInfo<SocketAddr>>()
.map(|info| info.0);
let exempt = parts.extensions.get::<RateLimitExempt>().is_some();
__check_throttle(
state,
ROOM_RATE_ROUTE_ID,
matched_path.as_ref().map(MatchedPath::as_str),
ThrottleSpec::Inline {
limit: per_minute,
per_secs: 60,
key: Some(KeyStrategy::Ip),
},
&parts.headers,
peer,
None,
None,
exempt,
)
.await?;
Ok(Self)
}
}
#[autumn_web::secured]
async fn rooms_create(
_: RoomRateGate,
State(state): State<AppState>,
) -> AutumnResult<Json<RoomSnapshot>> {
let snapshot = room_service(&state)?
.create()
.await
.map_err(RoomError::into_autumn)?;
Ok(Json(snapshot))
}
#[autumn_web::secured]
async fn rooms_join(
_: RoomRateGate,
State(state): State<AppState>,
Path(room_id): Path<String>,
Json(body): Json<JoinRequest>,
) -> AutumnResult<Json<JoinResponse>> {
let response = room_service(&state)?
.join(&room_id, body.display_name)
.await
.map_err(RoomError::into_autumn)?;
Ok(Json(response))
}
async fn rooms_leave(
_: RoomRateGate,
State(state): State<AppState>,
Path(room_id): Path<String>,
Json(body): Json<LeaveRequest>,
) -> AutumnResult<Json<RoomLeaveResponse>> {
room_service(&state)?
.leave(&room_id, &body.participant_id, &body.session_token)
.await
.map_err(RoomError::into_autumn)?;
Ok(Json(RoomLeaveResponse { left: true }))
}
async fn rooms_heartbeat(
_: RoomRateGate,
State(state): State<AppState>,
Path(room_id): Path<String>,
Json(body): Json<HeartbeatRequest>,
) -> AutumnResult<Json<HeartbeatResponse>> {
let response = room_service(&state)?
.heartbeat(&room_id, &body.participant_id, &body.session_token)
.await
.map_err(RoomError::into_autumn)?;
Ok(Json(response))
}
fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let value = headers
.get(autumn_web::reexports::http::header::AUTHORIZATION)?
.to_str()
.ok()?;
let (scheme, token) = value.split_once(' ')?;
scheme
.eq_ignore_ascii_case("bearer")
.then_some(token.trim())
}
async fn rooms_roster(
_: RoomRateGate,
State(state): State<AppState>,
Path(room_id): Path<String>,
headers: HeaderMap,
) -> AutumnResult<Json<RoomRoster>> {
let auth_token = bearer_token(&headers).unwrap_or_default();
let roster = room_service(&state)?
.roster(&room_id, auth_token)
.await
.map_err(RoomError::into_autumn)?;
Ok(Json(roster))
}
#[derive(Debug, Serialize)]
pub struct RoomLeaveResponse {
pub left: bool,
}
pub fn room_router() -> Router<AppState> {
Router::new()
.route("/rooms", post(rooms_create))
.route("/rooms/{room_id}/join", post(rooms_join))
.route("/rooms/{room_id}/leave", post(rooms_leave))
.route("/rooms/{room_id}/heartbeat", post(rooms_heartbeat))
.route("/rooms/{room_id}", get(rooms_roster))
}
#[must_use]
pub fn room_route_infos(api_prefix: &str) -> Vec<RouteInfo> {
let prefix = api_prefix.trim_end_matches('/');
vec![
gated(room_route(
"POST",
format!("{prefix}/rooms"),
"rooms::rooms_create",
)),
gated(room_route(
"POST",
format!("{prefix}/rooms/{{room_id}}/join"),
"rooms::rooms_join",
)),
room_route(
"POST",
format!("{prefix}/rooms/{{room_id}}/leave"),
"rooms::rooms_leave",
),
room_route(
"POST",
format!("{prefix}/rooms/{{room_id}}/heartbeat"),
"rooms::rooms_heartbeat",
),
room_route(
"GET",
format!("{prefix}/rooms/{{room_id}}"),
"rooms::rooms_roster",
),
]
}
fn gated(info: RouteInfo) -> RouteInfo {
RouteInfo {
classification: autumn_web::route_listing::RouteClassification::Gated,
..info
}
}
fn room_route(method: &str, path: String, handler: &str) -> RouteInfo {
RouteInfo {
method: method.to_owned(),
path,
handler: handler.to_owned(),
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::{
HeartbeatRequest, InMemoryRoomStore, LeaveRequest, MAX_DISPLAY_NAME_CHARS,
MAX_REAPER_TTL_SECONDS, ReapStats, Room, RoomError, RoomParticipant, RoomRateGate,
RoomService, RoomStore, SessionToken, bearer_token, clamp_reaper_ttl, renewed_expiry,
room_participant_path, room_route_infos, room_router, room_service, rooms_heartbeat,
rooms_roster, validate_room_segment,
};
use crate::config::MediaMtxConfig;
use crate::transport::MediaUrls;
use autumn_web::AppState;
use autumn_web::reexports::axum::Router;
use autumn_web::reexports::axum::body::Body;
use autumn_web::reexports::axum::extract::{ConnectInfo, Path, State};
use autumn_web::reexports::http::{HeaderMap, Request, StatusCode, header};
use autumn_web::session::{MemoryStore, SessionConfig, SessionLayer, SessionStore};
use chrono::{DateTime, Duration, Utc};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::sync::Arc;
use tower::ServiceExt;
fn urls() -> MediaUrls {
MediaUrls::from_config(&MediaMtxConfig::default())
}
fn service(namespace: &str) -> RoomService {
RoomService::new(
Arc::new(InMemoryRoomStore::new(6)),
urls(),
namespace,
Duration::seconds(300),
6,
)
}
#[test]
fn reaper_ttl_clamps_overflowing_value_without_panicking() {
assert_eq!(clamp_reaper_ttl(900), Duration::seconds(900));
assert_eq!(
clamp_reaper_ttl(u64::MAX),
Duration::seconds(MAX_REAPER_TTL_SECONDS)
);
}
#[test]
fn segment_guard_accepts_uuid_and_normal_labels_rejects_traversal() {
assert!(validate_room_segment("tenant-a").is_ok());
assert!(validate_room_segment("a1b2_c3-d4").is_ok());
assert!(validate_room_segment(&uuid::Uuid::new_v4().to_string()).is_ok());
for bad in ["", ".", "..", "a/b", "a b", "a.b", "a%2e", "café"] {
assert!(
matches!(
validate_room_segment(bad),
Err(RoomError::InvalidSegment { .. })
),
"segment {bad:?} must be rejected"
);
}
}
#[test]
fn participant_path_scheme_with_and_without_namespace() {
assert_eq!(
room_participant_path("", "room1", "part1").unwrap(),
"room/room1/part1"
);
assert_eq!(
room_participant_path("tenant-a", "room1", "part1").unwrap(),
"room/tenant-a/room1/part1"
);
assert!(matches!(
room_participant_path("..", "room1", "part1"),
Err(RoomError::InvalidSegment { .. })
));
}
#[test]
fn room_error_maps_to_expected_http_status() {
use autumn_web::reexports::http::StatusCode;
assert_eq!(
RoomError::RoomNotFound.into_autumn().status(),
StatusCode::NOT_FOUND
);
assert_eq!(
RoomError::ParticipantNotFound.into_autumn().status(),
StatusCode::NOT_FOUND
);
assert_eq!(
RoomError::RoomFull { max: 6 }.into_autumn().status(),
StatusCode::CONFLICT
);
assert_eq!(
RoomError::Unauthorized.into_autumn().status(),
StatusCode::UNAUTHORIZED
);
assert_eq!(
RoomError::InvalidSegment {
value: "..".to_owned()
}
.into_autumn()
.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
RoomError::InvalidMaxParticipants {
requested: 9,
cap: 6
}
.into_autumn()
.status(),
StatusCode::BAD_REQUEST
);
assert_eq!(
RoomError::RegistryFull { max: 10_000 }
.into_autumn()
.status(),
StatusCode::SERVICE_UNAVAILABLE
);
}
#[test]
fn session_token_debug_is_redacted() {
let token = SessionToken::generate();
let raw = token.expose().to_owned();
assert!(!raw.is_empty());
let rendered = format!("{token:?}");
assert!(
!rendered.contains(&raw),
"raw token must not appear in Debug: {rendered}"
);
assert!(rendered.contains("<redacted>"), "Debug: {rendered}");
}
#[test]
fn leave_request_debug_redacts_session_token() {
let req = LeaveRequest {
participant_id: "part-123".to_owned(),
session_token: "super-secret-token-value".to_owned(),
};
let rendered = format!("{req:?}");
assert!(
!rendered.contains("super-secret-token-value"),
"raw token must not appear in Debug: {rendered}"
);
assert!(
rendered.contains("part-123"),
"participant id must appear: {rendered}"
);
assert!(rendered.contains("<redacted>"), "Debug: {rendered}");
}
#[test]
fn session_token_serializes_the_real_value() {
let token = SessionToken::generate();
let json = serde_json::to_string(&token).unwrap();
assert_eq!(json, format!("\"{}\"", token.expose()));
}
#[tokio::test]
async fn create_room_mints_uuid_id_and_rejects_out_of_range_cap() {
let store = InMemoryRoomStore::new(6);
let snapshot = store.create_room("", 4).await.unwrap();
assert_eq!(uuid::Uuid::parse_str(&snapshot.id).map(|_| ()), Ok(()));
assert_eq!(snapshot.max_participants, 4);
assert!(snapshot.participants.is_empty());
assert!(matches!(
store.create_room("", 0).await,
Err(RoomError::InvalidMaxParticipants {
requested: 0,
cap: 6
})
));
assert!(matches!(
store.create_room("", 7).await,
Err(RoomError::InvalidMaxParticipants {
requested: 7,
cap: 6
})
));
}
#[tokio::test]
async fn create_room_backstops_the_absolute_ceiling_even_with_a_larger_hard_cap() {
let store = InMemoryRoomStore::new(50);
assert!(matches!(
store.create_room("", 50).await,
Err(RoomError::InvalidMaxParticipants {
requested: 50,
cap: 6
})
));
assert_eq!(store.create_room("", 6).await.unwrap().max_participants, 6);
}
#[tokio::test]
async fn create_room_rejects_once_registry_is_at_capacity() {
let store = InMemoryRoomStore::new(6).with_max_rooms(2);
assert!(store.create_room("", 4).await.is_ok());
assert!(store.create_room("", 4).await.is_ok());
assert!(matches!(
store.create_room("", 4).await,
Err(RoomError::RegistryFull { max: 2 })
));
assert_eq!(super::MAX_ROOMS, 10_000);
}
#[tokio::test]
async fn join_mints_token_and_composes_publish_and_subscribe_urls() {
let service = service("tenant-a");
let room = service.create().await.unwrap();
let first = service
.join(&room.id, Some("Ada".to_owned()))
.await
.unwrap();
assert!(first.subscribe.is_empty(), "first joiner sees no peers");
let publish_path = format!("room/tenant-a/{}/{}", room.id, first.participant_id);
assert_eq!(first.publish.path, publish_path);
assert_eq!(
first.publish.whip_url,
format!("http://127.0.0.1:8889/{publish_path}/whip")
);
assert!(!first.session_token.expose().is_empty());
let second = service
.join(&room.id, Some("Grace".to_owned()))
.await
.unwrap();
assert_eq!(second.subscribe.len(), 1);
let peer = &second.subscribe[0];
assert_eq!(peer.participant_id, first.participant_id);
let peer_path = format!("room/tenant-a/{}/{}", room.id, first.participant_id);
assert_eq!(peer.path, peer_path);
assert_eq!(
peer.whep_url,
format!("http://127.0.0.1:8889/{peer_path}/whep")
);
assert_eq!(second.room.participants.len(), 2);
}
#[tokio::test]
async fn join_enforces_participant_cap() {
let service = RoomService::new(
Arc::new(InMemoryRoomStore::new(6)),
urls(),
"",
Duration::seconds(300),
6,
);
let room = service.create().await.unwrap();
for _ in 0..6 {
service.join(&room.id, None).await.unwrap();
}
assert!(matches!(
service.join(&room.id, None).await,
Err(RoomError::RoomFull { max: 6 })
));
}
#[tokio::test]
async fn rooms_never_leak_across_namespaces() {
let store = Arc::new(InMemoryRoomStore::new(6));
let ns_a = RoomService::new(store.clone(), urls(), "a", Duration::seconds(300), 6);
let ns_b = RoomService::new(store, urls(), "b", Duration::seconds(300), 6);
let room = ns_a.create().await.unwrap();
let member = ns_a.join(&room.id, None).await.unwrap();
assert!(matches!(
ns_b.roster(&room.id, member.session_token.expose()).await,
Err(RoomError::RoomNotFound)
));
assert!(matches!(
ns_b.join(&room.id, None).await,
Err(RoomError::RoomNotFound)
));
assert!(
ns_a.roster(&room.id, member.session_token.expose())
.await
.is_ok()
);
}
#[test]
fn participant_verify_token_matches_by_value_only_ignoring_expiry() {
use super::RoomParticipant;
let now = Utc::now();
let participant = RoomParticipant {
id: "p1".to_owned(),
display_name: None,
joined_at: now,
token: SessionToken("secret-token-value".to_owned()),
token_expires_at: now - Duration::seconds(1),
last_seen_at: now,
};
assert!(
participant.verify_token("secret-token-value"),
"value-correct token verifies even though it is past its expiry"
);
assert!(
!participant.verify_token("wrong-token"),
"a wrong token value still fails"
);
}
#[tokio::test]
async fn leave_succeeds_with_expired_token_and_rejects_wrong_value() {
let service = RoomService::new(
Arc::new(InMemoryRoomStore::new(6)),
urls(),
"",
Duration::seconds(-10),
6,
);
let room = service.create().await.unwrap();
let joined = service.join(&room.id, None).await.unwrap();
assert!(
joined.token_expires_at < Utc::now(),
"token is already expired (advisory only)"
);
assert!(matches!(
service
.leave(&room.id, &joined.participant_id, "not-the-token")
.await,
Err(RoomError::Unauthorized)
));
assert!(
service
.leave(
&room.id,
&joined.participant_id,
joined.session_token.expose()
)
.await
.is_ok(),
"a participant can always leave with a value-correct token"
);
}
#[tokio::test]
async fn roster_returns_per_peer_subscribe_targets_for_a_member() {
let service = service("tenant-a");
let room = service.create().await.unwrap();
let first = service
.join(&room.id, Some("Ada".to_owned()))
.await
.unwrap();
let second = service
.join(&room.id, Some("Grace".to_owned()))
.await
.unwrap();
let roster = service
.roster(&room.id, first.session_token.expose())
.await
.unwrap();
assert_eq!(roster.participants.len(), 2);
let grace = roster
.participants
.iter()
.find(|entry| entry.participant_id == second.participant_id)
.expect("second joiner is in the first member's roster");
let grace_path = format!("room/tenant-a/{}/{}", room.id, second.participant_id);
assert_eq!(grace.path, grace_path);
assert_eq!(
grace.whep_url,
format!("http://127.0.0.1:8889/{grace_path}/whep")
);
assert_eq!(grace.display_name.as_deref(), Some("Grace"));
}
#[tokio::test]
async fn roster_is_member_gated_fail_closed_no_oracle() {
let service = service("tenant-a");
let room = service.create().await.unwrap();
service
.join(&room.id, Some("Ada".to_owned()))
.await
.unwrap();
assert!(matches!(
service.roster(&room.id, "").await,
Err(RoomError::RoomNotFound)
));
assert!(matches!(
service.roster(&room.id, "not-a-member-token").await,
Err(RoomError::RoomNotFound)
));
assert!(matches!(
service
.roster("00000000-0000-0000-0000-000000000000", "")
.await,
Err(RoomError::RoomNotFound)
));
}
#[tokio::test]
async fn roster_poll_surfaces_a_late_joiner_to_an_earlier_member() {
let service = service("tenant-a");
let room = service.create().await.unwrap();
let a = service
.join(&room.id, Some("Ada".to_owned()))
.await
.unwrap();
assert!(
service
.roster(&room.id, a.session_token.expose())
.await
.unwrap()
.participants
.iter()
.all(|entry| entry.participant_id == a.participant_id)
);
let b = service
.join(&room.id, Some("Grace".to_owned()))
.await
.unwrap();
let roster = service
.roster(&room.id, a.session_token.expose())
.await
.unwrap();
let b_entry = roster
.participants
.iter()
.find(|entry| entry.participant_id == b.participant_id)
.expect("A's later poll surfaces the late joiner B");
let b_path = format!("room/tenant-a/{}/{}", room.id, b.participant_id);
assert_eq!(
b_entry.whep_url,
format!("http://127.0.0.1:8889/{b_path}/whep")
);
}
#[tokio::test]
async fn roster_json_never_carries_tokens() {
let service = service("tenant-a");
let room = service.create().await.unwrap();
let joined = service
.join(&room.id, Some("Ada".to_owned()))
.await
.unwrap();
let roster = service
.roster(&room.id, joined.session_token.expose())
.await
.unwrap();
let json = serde_json::to_string(&roster).unwrap();
assert!(
!json.contains(joined.session_token.expose()),
"roster json leaked a token: {json}"
);
assert!(
!json.contains("token"),
"roster json has a token field: {json}"
);
assert!(json.contains("\"namespace\":\"tenant-a\""));
assert!(json.contains("whep_url"));
assert!(json.contains("Ada"));
}
#[tokio::test]
async fn empty_namespace_is_omitted_from_roster_json() {
let service = service("");
let room = service.create().await.unwrap();
let joined = service.join(&room.id, None).await.unwrap();
let roster = service
.roster(&room.id, joined.session_token.expose())
.await
.unwrap();
let json = serde_json::to_string(&roster).unwrap();
assert!(
!json.contains("namespace"),
"empty namespace must be omitted: {json}"
);
}
#[test]
fn room_route_infos_cover_the_five_routes_under_prefix() {
let infos = room_route_infos("/api/media");
let pairs: Vec<(&str, &str)> = infos
.iter()
.map(|info| (info.method.as_str(), info.path.as_str()))
.collect();
assert_eq!(
pairs,
vec![
("POST", "/api/media/rooms"),
("POST", "/api/media/rooms/{room_id}/join"),
("POST", "/api/media/rooms/{room_id}/leave"),
("POST", "/api/media/rooms/{room_id}/heartbeat"),
("GET", "/api/media/rooms/{room_id}"),
]
);
assert_eq!(room_route_infos("/api/media/")[0].path, "/api/media/rooms");
}
#[test]
fn room_route_infos_classify_secured_create_and_join_as_gated() {
use autumn_web::route_listing::RouteClassification;
let classes: Vec<RouteClassification> = room_route_infos("/api/media")
.iter()
.map(|info| info.classification)
.collect();
assert_eq!(
classes,
vec![
RouteClassification::Gated,
RouteClassification::Gated,
RouteClassification::Unclassified,
RouteClassification::Unclassified,
RouteClassification::Unclassified,
]
);
}
#[test]
fn bearer_token_parses_case_insensitive_scheme_and_rejects_malformed() {
let with = |value: &str| {
let mut headers = HeaderMap::new();
headers.insert(
autumn_web::reexports::http::header::AUTHORIZATION,
value.parse().unwrap(),
);
headers
};
assert_eq!(bearer_token(&with("Bearer abc123")), Some("abc123"));
assert_eq!(bearer_token(&with("bearer abc123 ")), Some("abc123"));
assert_eq!(bearer_token(&with("BEARER abc123")), Some("abc123"));
assert_eq!(bearer_token(&with("Basic abc123")), None);
assert_eq!(bearer_token(&with("abc123")), None);
assert_eq!(bearer_token(&HeaderMap::new()), None);
}
#[tokio::test]
async fn create_and_join_require_authenticated_sessions() {
let store = MemoryStore::new();
store
.save(
"member-session",
HashMap::from([("user_id".to_owned(), "member-1".to_owned())]),
)
.await
.expect("save authenticated session");
let state = AppState::for_test();
let service = service("tenant-a");
let room_id = service.create().await.expect("create room").id;
state.insert_extension(service);
let app = room_router()
.layer(SessionLayer::new(store, SessionConfig::default()))
.with_state(state);
let unauthenticated_create = app
.clone()
.oneshot(
Request::post("/rooms")
.body(Body::empty())
.expect("create request"),
)
.await
.expect("create response");
assert_eq!(unauthenticated_create.status(), StatusCode::UNAUTHORIZED);
let authenticated_create = app
.clone()
.oneshot(
Request::post("/rooms")
.header(header::COOKIE, "autumn.sid=member-session")
.body(Body::empty())
.expect("authenticated create request"),
)
.await
.expect("authenticated create response");
assert_eq!(authenticated_create.status(), StatusCode::OK);
let unauthenticated_join = app
.clone()
.oneshot(
Request::post(format!("/rooms/{room_id}/join"))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"display_name":"Mallory"}"#))
.expect("join request"),
)
.await
.expect("join response");
assert_eq!(unauthenticated_join.status(), StatusCode::UNAUTHORIZED);
let authenticated_join = app
.oneshot(
Request::post(format!("/rooms/{room_id}/join"))
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, "autumn.sid=member-session")
.body(Body::from(r#"{"display_name":"Ada"}"#))
.expect("authenticated join request"),
)
.await
.expect("authenticated join response");
assert_eq!(authenticated_join.status(), StatusCode::OK);
}
#[tokio::test]
async fn service_round_trip_create_join_roster() {
let state = AppState::for_test();
state.insert_extension(service("tenant-a"));
let svc = room_service(&state).expect("service installed");
let created = svc.create().await.expect("create");
assert!(created.participants.is_empty());
let room_id = created.id.clone();
let joined = svc
.join(&room_id, Some("Ada".to_owned()))
.await
.expect("join");
assert!(!joined.session_token.expose().is_empty());
assert!(!joined.participant_id.is_empty());
let mut headers = HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
format!("Bearer {}", joined.session_token.expose())
.parse()
.unwrap(),
);
let roster = rooms_roster(
RoomRateGate,
State(state.clone()),
Path(room_id.clone()),
headers,
)
.await
.expect("roster")
.0;
assert_eq!(roster.participants.len(), 1);
assert_eq!(roster.participants[0].participant_id, joined.participant_id);
assert!(!roster.participants[0].whep_url.is_empty());
let unauth = rooms_roster(RoomRateGate, State(state), Path(room_id), HeaderMap::new())
.await
.expect_err("no bearer → not found");
assert_eq!(unauth.status(), StatusCode::NOT_FOUND);
}
fn roster_from(ip: [u8; 4], room_id: &str) -> Request<Body> {
Request::get(format!("/rooms/{room_id}"))
.extension(ConnectInfo(SocketAddr::from((ip, 40_000))))
.body(Body::empty())
.expect("roster request")
}
fn heartbeat_from(ip: [u8; 4], room_id: &str) -> Request<Body> {
Request::post(format!("/rooms/{room_id}/heartbeat"))
.extension(ConnectInfo(SocketAddr::from((ip, 40_000))))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"participant_id":"ghost","session_token":"nope"}"#,
))
.expect("heartbeat request")
}
async fn statuses(app: &Router<()>, requests: Vec<Request<Body>>) -> Vec<StatusCode> {
let mut out = Vec::new();
for request in requests {
out.push(
app.clone()
.oneshot(request)
.await
.expect("response")
.status(),
);
}
out
}
#[tokio::test]
async fn room_routes_return_429_past_the_per_ip_limit() {
let state = AppState::for_test();
state.insert_extension(service("").with_rate_limit_per_minute(2));
let app = room_router().with_state(state);
let alice = [203, 0, 113, 7];
let seen = statuses(
&app,
vec![
roster_from(alice, "r1"),
roster_from(alice, "r1"),
roster_from(alice, "r1"),
],
)
.await;
assert_eq!(
seen,
[
StatusCode::NOT_FOUND,
StatusCode::NOT_FOUND,
StatusCode::TOO_MANY_REQUESTS
]
);
let bob = statuses(&app, vec![roster_from([198, 51, 100, 9], "r1")]).await;
assert_eq!(bob, [StatusCode::NOT_FOUND]);
}
#[tokio::test]
async fn room_rate_limit_keeps_one_bucket_per_route() {
let state = AppState::for_test();
state.insert_extension(service("").with_rate_limit_per_minute(1));
let app = room_router().with_state(state);
let ip = [203, 0, 113, 8];
let seen = statuses(
&app,
vec![
roster_from(ip, "r1"),
heartbeat_from(ip, "r1"),
roster_from(ip, "r1"),
heartbeat_from(ip, "r1"),
],
)
.await;
assert_eq!(
seen,
[
StatusCode::NOT_FOUND,
StatusCode::NOT_FOUND,
StatusCode::TOO_MANY_REQUESTS,
StatusCode::TOO_MANY_REQUESTS
]
);
}
#[tokio::test]
async fn room_rate_limit_answers_429_before_the_body_is_read() {
let state = AppState::for_test();
state.insert_extension(service("").with_rate_limit_per_minute(1));
let app = room_router().with_state(state);
let ip = [203, 0, 113, 10];
let broken = || {
Request::post("/rooms/r1/heartbeat")
.extension(ConnectInfo(SocketAddr::from((ip, 40_000))))
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from("{"))
.expect("request")
};
let seen = statuses(&app, vec![broken(), broken()]).await;
assert_ne!(seen[0], StatusCode::TOO_MANY_REQUESTS, "first is in budget");
assert_eq!(
seen[1],
StatusCode::TOO_MANY_REQUESTS,
"429, not a body error"
);
}
#[tokio::test]
async fn create_checks_the_session_before_the_rate_limit() {
let store = MemoryStore::new();
store
.save(
"member-session",
HashMap::from([("user_id".to_owned(), "member-1".to_owned())]),
)
.await
.expect("save session");
let state = AppState::for_test();
state.insert_extension(service("").with_rate_limit_per_minute(1));
let app = room_router()
.layer(SessionLayer::new(store, SessionConfig::default()))
.with_state(state);
let ip = [203, 0, 113, 11];
let create = |cookie: bool| {
let mut request =
Request::post("/rooms").extension(ConnectInfo(SocketAddr::from((ip, 40_000))));
if cookie {
request = request.header(header::COOKIE, "autumn.sid=member-session");
}
request.body(Body::empty()).expect("request")
};
let seen = statuses(
&app,
vec![create(false), create(false), create(true), create(true)],
)
.await;
assert_eq!(
seen,
[
StatusCode::UNAUTHORIZED,
StatusCode::UNAUTHORIZED,
StatusCode::OK,
StatusCode::TOO_MANY_REQUESTS
]
);
}
#[tokio::test]
async fn room_rate_limit_is_off_by_default() {
let state = AppState::for_test();
let service = service("");
assert_eq!(service.rate_limit_per_minute(), 0);
state.insert_extension(service);
let app = room_router().with_state(state);
let ip = [203, 0, 113, 9];
let seen = statuses(&app, (0..5).map(|_| roster_from(ip, "r1")).collect()).await;
assert!(seen.iter().all(|status| *status == StatusCode::NOT_FOUND));
}
#[test]
fn room_service_missing_extension_is_500() {
let state = AppState::for_test();
let Err(err) = room_service(&state) else {
panic!("missing RoomService extension must return an error");
};
assert_eq!(err.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
fn axum_json<T>(value: T) -> autumn_web::reexports::axum::Json<T> {
autumn_web::reexports::axum::Json(value)
}
fn seed_room(
store: &InMemoryRoomStore,
namespace: &str,
room_id: &str,
created_at: DateTime<Utc>,
seats: &[(&str, &str, DateTime<Utc>)],
) {
let mut participants = HashMap::new();
for (id, token, last_seen_at) in seats {
participants.insert(
(*id).to_owned(),
RoomParticipant {
id: (*id).to_owned(),
display_name: None,
joined_at: *last_seen_at,
token: SessionToken((*token).to_owned()),
token_expires_at: *last_seen_at,
last_seen_at: *last_seen_at,
},
);
}
store.rooms.write().unwrap().insert(
(namespace.to_owned(), room_id.to_owned()),
Room {
id: room_id.to_owned(),
namespace: namespace.to_owned(),
max_participants: 6,
created_at,
participants,
},
);
}
fn seat_count(store: &InMemoryRoomStore, namespace: &str, room_id: &str) -> Option<usize> {
store
.rooms
.read()
.unwrap()
.get(&(namespace.to_owned(), room_id.to_owned()))
.map(|room| room.participants.len())
}
#[tokio::test]
async fn reap_evicts_stale_participant_but_keeps_a_fresh_one_and_the_room() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
let ttl = Duration::minutes(30);
seed_room(
&store,
"",
"room-1",
now - Duration::hours(2),
&[
("stale", "tok-stale", now - Duration::hours(1)),
("fresh", "tok-fresh", now - Duration::minutes(5)),
],
);
let stats = store.reap_stale(now, ttl).await;
assert_eq!(stats.participants_reaped, 1);
assert_eq!(stats.rooms_reaped, 0);
assert_eq!(seat_count(&store, "", "room-1"), Some(1));
assert!(
store
.roster("", "room-1", "tok-fresh", Duration::hours(12))
.await
.is_ok()
);
assert!(matches!(
store
.roster("", "room-1", "tok-stale", Duration::hours(12))
.await,
Err(RoomError::RoomNotFound)
));
}
#[tokio::test]
async fn reap_drops_a_room_emptied_by_reaping() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
seed_room(
&store,
"",
"room-1",
now - Duration::hours(2),
&[("stale", "tok", now - Duration::hours(1))],
);
let stats = store.reap_stale(now, Duration::minutes(30)).await;
assert_eq!(stats.participants_reaped, 1);
assert_eq!(stats.rooms_reaped, 1);
assert_eq!(
seat_count(&store, "", "room-1"),
None,
"emptied room dropped"
);
}
#[tokio::test]
async fn reap_drops_created_never_joined_room_past_ttl_but_keeps_a_fresh_empty_room() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
let ttl = Duration::minutes(30);
seed_room(&store, "", "old-empty", now - Duration::hours(1), &[]);
seed_room(&store, "", "new-empty", now - Duration::minutes(5), &[]);
let stats = store.reap_stale(now, ttl).await;
assert_eq!(stats.rooms_reaped, 1);
assert_eq!(stats.participants_reaped, 0);
assert_eq!(
seat_count(&store, "", "old-empty"),
None,
"lingering empty room dropped"
);
assert_eq!(
seat_count(&store, "", "new-empty"),
Some(0),
"a just-created empty room within the TTL survives"
);
}
#[tokio::test]
async fn reap_never_crosses_namespaces() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
let ttl = Duration::minutes(30);
seed_room(
&store,
"a",
"shared-id",
now - Duration::hours(2),
&[("stale", "tok-a", now - Duration::hours(1))],
);
seed_room(
&store,
"b",
"shared-id",
now - Duration::minutes(1),
&[("fresh", "tok-b", now - Duration::minutes(1))],
);
let stats = store.reap_stale(now, ttl).await;
assert_eq!(stats.rooms_reaped, 1);
assert_eq!(stats.participants_reaped, 1);
assert_eq!(
seat_count(&store, "a", "shared-id"),
None,
"ns a room reaped"
);
assert_eq!(
seat_count(&store, "b", "shared-id"),
Some(1),
"same room_id in ns b is untouched"
);
}
#[tokio::test]
async fn reap_on_a_clean_store_is_a_zero_count_no_op() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
seed_room(
&store,
"",
"room-1",
now - Duration::minutes(1),
&[("fresh", "tok", now - Duration::minutes(1))],
);
let stats = store.reap_stale(now, Duration::minutes(30)).await;
assert_eq!(stats, ReapStats::default());
assert_eq!(seat_count(&store, "", "room-1"), Some(1));
let empty = InMemoryRoomStore::new(6);
assert_eq!(
empty.reap_stale(Utc::now(), Duration::minutes(30)).await,
ReapStats::default()
);
}
#[tokio::test]
async fn roster_poll_refreshes_last_seen_so_an_active_participant_survives_a_sweep() {
let store = InMemoryRoomStore::new(6);
let stale = Utc::now() - Duration::hours(1);
seed_room(
&store,
"",
"room-1",
Utc::now() - Duration::hours(2),
&[("active", "tok-a", stale), ("idle", "tok-b", stale)],
);
assert!(
store
.roster("", "room-1", "tok-a", Duration::hours(12))
.await
.is_ok()
);
let stats = store.reap_stale(Utc::now(), Duration::minutes(30)).await;
assert_eq!(
stats.participants_reaped, 1,
"only the un-polled seat is reaped"
);
assert_eq!(stats.rooms_reaped, 0);
assert!(
store
.roster("", "room-1", "tok-a", Duration::hours(12))
.await
.is_ok(),
"the actively-polling participant survived the sweep"
);
assert!(matches!(
store
.roster("", "room-1", "tok-b", Duration::hours(12))
.await,
Err(RoomError::RoomNotFound)
));
}
#[tokio::test]
async fn heartbeat_refreshes_last_seen_so_a_non_polling_participant_survives_a_sweep() {
let store = InMemoryRoomStore::new(6);
let stale = Utc::now() - Duration::hours(1);
seed_room(
&store,
"",
"room-1",
Utc::now() - Duration::hours(2),
&[("beating", "tok-a", stale), ("silent", "tok-b", stale)],
);
store
.heartbeat(
"",
"room-1",
"beating",
"tok-a",
Duration::seconds(300),
Duration::hours(12),
)
.await
.expect("heartbeat");
let stats = store.reap_stale(Utc::now(), Duration::minutes(30)).await;
assert_eq!(stats.participants_reaped, 1, "only the silent seat reaped");
assert_eq!(stats.rooms_reaped, 0);
assert!(
store
.roster("", "room-1", "tok-a", Duration::hours(12))
.await
.is_ok()
);
assert!(matches!(
store
.roster("", "room-1", "tok-b", Duration::hours(12))
.await,
Err(RoomError::RoomNotFound)
));
}
#[tokio::test]
async fn heartbeat_renews_the_advisory_token_expiry() {
let store = InMemoryRoomStore::new(6);
let stale = Utc::now() - Duration::hours(1);
seed_room(&store, "", "room-1", stale, &[("p1", "tok", stale)]);
let before = Utc::now();
let renewed = store
.heartbeat(
"",
"room-1",
"p1",
"tok",
Duration::seconds(300),
Duration::hours(12),
)
.await
.expect("heartbeat");
assert!(
renewed > stale,
"expiry moved forward from the seeded value"
);
assert!(renewed >= before + Duration::seconds(300));
assert!(renewed <= Utc::now() + Duration::seconds(300));
let stored = store
.rooms
.read()
.unwrap()
.get(&(String::new(), "room-1".to_owned()))
.and_then(|room| room.participants.get("p1"))
.map(|participant| participant.token_expires_at)
.expect("participant present");
assert_eq!(stored, renewed);
}
#[test]
fn renewed_expiry_holds_the_session_limit_at_its_edges() {
let joined = Utc::now();
let ttl = Duration::seconds(300);
let max = Duration::hours(1);
assert_eq!(renewed_expiry(joined, joined, ttl, max), Some(joined + ttl));
let near = joined + max - Duration::seconds(1);
assert_eq!(renewed_expiry(joined, near, ttl, max), Some(joined + max));
assert_eq!(renewed_expiry(joined, joined + max, ttl, max), None);
assert_eq!(
renewed_expiry(joined, joined + max + Duration::seconds(1), ttl, max),
None
);
}
#[tokio::test]
async fn heartbeat_caps_the_expiry_at_the_session_limit() {
let store = InMemoryRoomStore::new(6);
let joined = Utc::now() - Duration::hours(12) + Duration::minutes(1);
seed_room(&store, "", "room-1", joined, &[("p1", "tok", joined)]);
let renewed = store
.heartbeat(
"",
"room-1",
"p1",
"tok",
Duration::seconds(300),
Duration::hours(12),
)
.await
.expect("heartbeat inside the session limit");
assert_eq!(renewed, joined + Duration::hours(12));
}
#[tokio::test]
async fn heartbeat_after_the_session_limit_is_refused() {
let store = InMemoryRoomStore::new(6);
let joined = Utc::now() - Duration::hours(13);
let last_seen = Utc::now() - Duration::minutes(1);
seed_room(&store, "", "room-1", joined, &[("p1", "tok", joined)]);
set_last_seen(&store, "room-1", "p1", last_seen);
let result = store
.heartbeat(
"",
"room-1",
"p1",
"tok",
Duration::seconds(300),
Duration::hours(12),
)
.await;
assert!(matches!(result, Err(RoomError::RoomNotFound)));
let (seen, expires) = seat_clocks(&store, "room-1", "p1");
assert_eq!(seen, last_seen, "no liveness refresh");
assert_eq!(expires, joined, "no expiry renewal");
}
#[tokio::test]
async fn roster_after_the_session_limit_is_refused() {
let store = InMemoryRoomStore::new(6);
let joined = Utc::now() - Duration::hours(13);
let last_seen = Utc::now() - Duration::minutes(1);
seed_room(&store, "", "room-1", joined, &[("p1", "tok", joined)]);
set_last_seen(&store, "room-1", "p1", last_seen);
let result = store.roster("", "room-1", "tok", Duration::hours(12)).await;
assert!(matches!(result, Err(RoomError::RoomNotFound)));
let (seen, _) = seat_clocks(&store, "room-1", "p1");
assert_eq!(seen, last_seen, "no liveness refresh");
}
#[tokio::test]
async fn room_service_roster_applies_its_session_limit() {
let store = Arc::new(InMemoryRoomStore::new(6));
let joined = Utc::now() - Duration::hours(2);
seed_room(&store, "", "room-1", joined, &[("p1", "tok", joined)]);
let service = RoomService::new(
store,
MediaUrls::from_config(&MediaMtxConfig::default()),
"",
Duration::seconds(300),
6,
)
.with_session_max(Duration::hours(1));
assert!(matches!(
service.roster("room-1", "tok").await,
Err(RoomError::RoomNotFound)
));
}
#[tokio::test]
async fn room_service_join_never_mints_a_token_past_the_session_limit() {
let service = RoomService::new(
Arc::new(InMemoryRoomStore::new(6)),
MediaUrls::from_config(&MediaMtxConfig::default()),
"",
Duration::hours(2),
6,
)
.with_session_max(Duration::hours(1));
let room = service.create().await.expect("create");
let before = Utc::now();
let joined = service.join(&room.id, None).await.expect("join");
assert!(joined.token_expires_at <= Utc::now() + Duration::hours(1));
assert!(joined.token_expires_at >= before + Duration::hours(1));
}
fn service_over(store: Arc<InMemoryRoomStore>) -> RoomService {
RoomService::new(store, urls(), "", Duration::seconds(300), 6)
}
fn seats_in(store: &InMemoryRoomStore, room_id: &str) -> usize {
store.rooms.read().unwrap()[&(String::new(), room_id.to_owned())]
.participants
.len()
}
#[tokio::test]
async fn join_refuses_a_display_name_past_the_limit_and_stores_nothing() {
let store = Arc::new(InMemoryRoomStore::new(6));
let service = service_over(store.clone());
let room = service.create().await.expect("create");
for name in ["a", "é"].map(|c| c.repeat(MAX_DISPLAY_NAME_CHARS + 1)) {
let result = service.join(&room.id, Some(name)).await;
assert!(matches!(
result,
Err(RoomError::DisplayNameTooLong {
max: MAX_DISPLAY_NAME_CHARS
})
));
}
assert_eq!(seats_in(&store, &room.id), 0, "no seat is stored");
}
#[tokio::test]
async fn join_accepts_a_display_name_at_the_limit_counted_in_chars() {
let store = Arc::new(InMemoryRoomStore::new(6));
let service = service_over(store.clone());
let room = service.create().await.expect("create");
let name = "é".repeat(MAX_DISPLAY_NAME_CHARS);
let joined = service.join(&room.id, Some(name.clone())).await;
assert!(joined.is_ok(), "a name at the limit joins: {joined:?}");
let roster = store.rooms.read().unwrap()[&(String::new(), room.id.clone())]
.participants
.values()
.map(|seat| seat.display_name.clone())
.collect::<Vec<_>>();
assert_eq!(roster, vec![Some(name)]);
}
#[test]
fn display_name_too_long_is_a_bad_request() {
let error = RoomError::DisplayNameTooLong { max: 64 }.into_autumn();
assert_eq!(error.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn join_route_answers_400_for_a_display_name_past_the_limit() {
let sessions = MemoryStore::new();
sessions
.save(
"member-session",
HashMap::from([("user_id".to_owned(), "member-1".to_owned())]),
)
.await
.expect("save authenticated session");
let store = Arc::new(InMemoryRoomStore::new(6));
let service = service_over(store.clone());
let room_id = service.create().await.expect("create room").id;
let state = AppState::for_test();
state.insert_extension(service);
let app = room_router()
.layer(SessionLayer::new(sessions, SessionConfig::default()))
.with_state(state);
let body = serde_json::json!({ "display_name": "a".repeat(MAX_DISPLAY_NAME_CHARS + 1) });
let response = app
.oneshot(
Request::post(format!("/rooms/{room_id}/join"))
.header(header::CONTENT_TYPE, "application/json")
.header(header::COOKIE, "autumn.sid=member-session")
.body(Body::from(body.to_string()))
.expect("join request"),
)
.await
.expect("join response");
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = autumn_web::reexports::axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.expect("body");
assert!(
String::from_utf8_lossy(&body).contains("display_name"),
"the 400 names the field, not a JSON parse error"
);
assert_eq!(seats_in(&store, &room_id), 0, "no seat is stored");
}
#[tokio::test]
async fn room_service_heartbeat_applies_its_session_limit() {
let store = Arc::new(InMemoryRoomStore::new(6));
let joined = Utc::now() - Duration::hours(2);
seed_room(&store, "", "room-1", joined, &[("p1", "tok", joined)]);
let service = RoomService::new(
store,
MediaUrls::from_config(&MediaMtxConfig::default()),
"",
Duration::seconds(300),
6,
)
.with_session_max(Duration::hours(1));
assert!(matches!(
service.heartbeat("room-1", "p1", "tok").await,
Err(RoomError::RoomNotFound)
));
}
fn set_last_seen(store: &InMemoryRoomStore, room_id: &str, id: &str, at: DateTime<Utc>) {
store
.rooms
.write()
.unwrap()
.get_mut(&(String::new(), room_id.to_owned()))
.and_then(|room| room.participants.get_mut(id))
.expect("seat present")
.last_seen_at = at;
}
fn seat_clocks(
store: &InMemoryRoomStore,
room_id: &str,
id: &str,
) -> (DateTime<Utc>, DateTime<Utc>) {
store
.rooms
.read()
.unwrap()
.get(&(String::new(), room_id.to_owned()))
.and_then(|room| room.participants.get(id))
.map(|seat| (seat.last_seen_at, seat.token_expires_at))
.expect("seat present")
}
#[tokio::test]
async fn heartbeat_keeps_the_token_value_stable() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
seed_room(&store, "", "room-1", now, &[("p1", "tok", now)]);
store
.heartbeat(
"",
"room-1",
"p1",
"tok",
Duration::seconds(300),
Duration::hours(12),
)
.await
.expect("heartbeat");
assert!(
store
.roster("", "room-1", "tok", Duration::hours(12))
.await
.is_ok(),
"the original token still authenticates after a heartbeat"
);
}
#[tokio::test]
async fn heartbeat_is_fail_closed_with_no_membership_oracle() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
seed_room(&store, "", "room-1", now, &[("p1", "tok", now)]);
let ttl = Duration::seconds(300);
for (room, participant, token, case) in [
("nope", "p1", "tok", "unknown room"),
("room-1", "ghost", "tok", "unknown participant"),
("room-1", "p1", "wrong", "wrong token"),
] {
assert!(
matches!(
store
.heartbeat("", room, participant, token, ttl, Duration::hours(12))
.await,
Err(RoomError::RoomNotFound)
),
"{case} must be indistinguishable from a missing room"
);
}
}
#[tokio::test]
async fn heartbeat_rejects_a_sibling_participants_token() {
let store = InMemoryRoomStore::new(6);
let now = Utc::now();
seed_room(
&store,
"",
"room-1",
now,
&[("p1", "tok-1", now), ("p2", "tok-2", now)],
);
assert!(matches!(
store
.heartbeat(
"",
"room-1",
"p2",
"tok-1",
Duration::seconds(300),
Duration::hours(12)
)
.await,
Err(RoomError::RoomNotFound)
));
}
#[tokio::test]
async fn heartbeat_never_crosses_namespaces() {
let store = InMemoryRoomStore::new(6);
let stale = Utc::now() - Duration::hours(1);
seed_room(&store, "tenant-a", "room-1", stale, &[("p1", "tok", stale)]);
assert!(matches!(
store
.heartbeat(
"tenant-b",
"room-1",
"p1",
"tok",
Duration::seconds(300),
Duration::hours(12)
)
.await,
Err(RoomError::RoomNotFound)
));
let stats = store.reap_stale(Utc::now(), Duration::minutes(30)).await;
assert_eq!(stats.participants_reaped, 1);
}
#[tokio::test]
async fn handler_heartbeat_round_trips_and_fails_closed() {
let state = AppState::for_test();
state.insert_extension(service("tenant-a"));
let svc = room_service(&state).expect("service installed");
let created = svc.create().await.expect("create");
let joined = svc.join(&created.id, None).await.expect("join");
let beat = rooms_heartbeat(
RoomRateGate,
State(state.clone()),
Path(created.id.clone()),
axum_json(HeartbeatRequest {
participant_id: joined.participant_id.clone(),
session_token: joined.session_token.expose().to_owned(),
}),
)
.await
.expect("heartbeat")
.0;
assert!(beat.alive);
assert!(beat.token_expires_at >= joined.token_expires_at);
let denied = rooms_heartbeat(
RoomRateGate,
State(state),
Path(created.id),
axum_json(HeartbeatRequest {
participant_id: joined.participant_id,
session_token: "wrong".to_owned(),
}),
)
.await
.expect_err("wrong token → not found");
assert_eq!(
denied.status(),
autumn_web::reexports::http::StatusCode::NOT_FOUND
);
}
#[test]
fn heartbeat_request_debug_redacts_the_session_token() {
let rendered = format!(
"{:?}",
HeartbeatRequest {
participant_id: "p1".to_owned(),
session_token: "super-secret".to_owned(),
}
);
assert!(rendered.contains("p1"));
assert!(!rendered.contains("super-secret"));
assert!(rendered.contains("<redacted>"));
}
}