use std::sync::Arc;
use std::time::Duration;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::client::Client;
use crate::models::{CallRequest, CallResponse, MemberResponse};
use super::error::{Result, RtcError};
const LOCATION_HINT_URL: &str = "https://hint.stream-io-video.com/";
const CF_POP_HEADER: &str = "x-amz-cf-pop";
pub const FALLBACK_LOCATION: &str = "auto";
#[derive(Debug, Clone, Default, Serialize)]
pub struct JoinCallRequest {
pub location: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub create: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub data: Option<CallRequest>,
#[serde(skip_serializing_if = "Option::is_none")]
pub members_limit: Option<i32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub notify: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub ring: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub video: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub migrating_from: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub migrating_from_list: Vec<String>,
}
#[derive(Clone, Default, Deserialize)]
#[serde(default)]
pub struct IceServer {
pub urls: Vec<String>,
pub username: String,
pub password: String,
}
impl std::fmt::Debug for IceServer {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IceServer")
.field("urls", &self.urls)
.field("username", &self.username)
.field("password", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct SfuServer {
pub edge_name: String,
pub url: String,
pub ws_endpoint: String,
}
#[derive(Clone, Default, Deserialize)]
#[serde(default)]
pub struct Credentials {
pub server: SfuServer,
pub token: String,
pub ice_servers: Vec<IceServer>,
}
impl std::fmt::Debug for Credentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Credentials")
.field("server", &self.server)
.field("token", &"<redacted>")
.field("ice_servers", &self.ice_servers)
.finish()
}
}
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(default)]
pub struct StatsOptions {
pub reporting_interval_ms: i32,
pub enable_rtc_stats: bool,
}
#[derive(Clone, Default, Deserialize)]
#[serde(default)]
pub struct JoinCallResponse {
pub duration: String,
pub created: bool,
pub call: CallResponse,
pub members: Vec<MemberResponse>,
pub own_capabilities: Vec<String>,
pub credentials: Credentials,
pub stats_options: StatsOptions,
pub membership: Option<Value>,
}
impl std::fmt::Debug for JoinCallResponse {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("JoinCallResponse")
.field("duration", &self.duration)
.field("created", &self.created)
.field("call", &self.call)
.field("members", &self.members)
.field("own_capabilities", &self.own_capabilities)
.field("credentials", &self.credentials)
.field("stats_options", &self.stats_options)
.field("membership", &self.membership)
.finish()
}
}
pub(crate) async fn join_call(
client: &Arc<Client>,
user_token: &str,
call_type: &str,
call_id: &str,
request: &JoinCallRequest,
query: &[(String, String)],
) -> Result<JoinCallResponse> {
let path = Client::build_path(
"/api/v2/video/call/{type}/{id}/join",
&[("type", call_type), ("id", call_id)],
);
let resp = client
.request_as_user(Method::POST, &path, query, Some(request), user_token)
.await?;
Ok(resp)
}
pub async fn discover_location(http: &reqwest::Client) -> String {
match discover_location_inner(http).await {
Some(loc) if !loc.is_empty() => loc,
_ => {
tracing::debug!("stream.rtc.location.fallback");
FALLBACK_LOCATION.to_owned()
}
}
}
async fn discover_location_inner(http: &reqwest::Client) -> Option<String> {
let response = http
.head(LOCATION_HINT_URL)
.timeout(Duration::from_secs(2))
.send()
.await
.ok()?;
let pop = response.headers().get(CF_POP_HEADER)?.to_str().ok()?;
let hint: String = pop.chars().take(3).collect();
if hint.len() == 3 { Some(hint) } else { None }
}
impl RtcError {
pub(crate) fn missing_credential(field: &str) -> Self {
RtcError::Coordinator(format!("join credentials missing {field}"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn credential_debug_redacts_every_secret() {
let response = JoinCallResponse {
credentials: Credentials {
server: SfuServer {
edge_name: "edge".to_owned(),
url: "https://sfu.example/twirp".to_owned(),
ws_endpoint: "wss://sfu.example/ws".to_owned(),
},
token: "sfu-token-must-not-leak".to_owned(),
ice_servers: vec![IceServer {
urls: vec!["turn:turn.example".to_owned()],
username: "turn-user".to_owned(),
password: "turn-password-must-not-leak".to_owned(),
}],
},
..Default::default()
};
let debug = format!("{response:?}");
assert!(!debug.contains("sfu-token-must-not-leak"));
assert!(!debug.contains("turn-password-must-not-leak"));
assert!(debug.contains("<redacted>"));
assert!(debug.contains("turn:turn.example"));
}
}