medea_control_api_proto/control/member.rs
1//! [`Member`] definitions.
2
3use std::{
4 collections::HashMap,
5 fmt::Write as _,
6 hash::{Hash, Hasher},
7 time::Duration,
8};
9
10use derive_more::with_trait::{AsRef, Display, Error, From, FromStr, Into};
11use ref_cast::RefCast;
12use secrecy::{ExposeSecret as _, SecretString};
13#[cfg(feature = "serde")]
14use serde::{Deserialize, Serialize, Serializer};
15use url::Url;
16
17use super::{Pipeline, endpoint, room};
18
19/// Media [`Element`] representing a client authorized to participate in some
20/// bigger media pipeline ([`Room`], for example).
21///
22/// [`Element`]: crate::Element
23/// [`Room`]: crate::Room
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct Member {
26 /// ID of this [`Member`] media [`Element`].
27 ///
28 /// [`Element`]: crate::Element
29 pub id: Id,
30
31 /// [`Spec`] of this [`Member`] media [`Element`].
32 ///
33 /// [`Element`]: crate::Element
34 pub spec: Spec,
35}
36
37/// Spec of a [`Member`] media [`Element`].
38///
39/// [`Element`]: crate::Element
40#[derive(Clone, Debug, Eq, PartialEq)]
41#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
42pub struct Spec {
43 /// Media [`Pipeline`] representing this [`Member`] media [`Element`].
44 ///
45 /// [`Element`]: crate::Element
46 pub pipeline: Pipeline<endpoint::Id, endpoint::Spec>,
47
48 /// [`Credentials`] to authenticate this [`Member`] in [Client API] with.
49 ///
50 /// [`None`] if no authentication is required.
51 ///
52 /// [Client API]: https://tinyurl.com/266y74tf
53 pub credentials: Option<Credentials>,
54
55 /// [`Url`] of the callback to fire when this [`Member`] establishes a
56 /// persistent connection with a media server via [Client API].
57 ///
58 /// [Client API]: https://tinyurl.com/266y74tf
59 pub on_join: Option<Url>,
60
61 /// [`Url`] of the callback to fire when this [`Member`] finishes a
62 /// persistent connection with a media server via [Client API].
63 ///
64 /// [Client API]: https://tinyurl.com/266y74tf
65 pub on_leave: Option<Url>,
66
67 /// Timeout of receiving heartbeat messages from this [`Member`] via
68 /// [Client API].
69 ///
70 /// Once reached, this [`Member`] is considered being idle.
71 ///
72 /// [Client API]: https://tinyurl.com/266y74tf
73 #[cfg_attr(feature = "serde", serde(default, with = "humantime_serde"))]
74 pub idle_timeout: Option<Duration>,
75
76 /// Timeout of reconnecting for this [`Member`] via [Client API].
77 ///
78 /// Once reached, this [`Member`] is considered disconnected.
79 ///
80 /// [Client API]: https://tinyurl.com/266y74tf
81 #[cfg_attr(feature = "serde", serde(default, with = "humantime_serde"))]
82 pub reconnect_timeout: Option<Duration>,
83
84 /// Interval of pinging with heartbeat messages this [`Member`] via
85 /// [Client API] by a media server.
86 ///
87 /// If [`None`] then the default interval of a media server is used, if
88 /// configured.
89 ///
90 /// [Client API]: https://tinyurl.com/266y74tf
91 #[cfg_attr(feature = "serde", serde(default, with = "humantime_serde"))]
92 pub ping_interval: Option<Duration>,
93}
94
95/// ID of a [`Member`] media [`Element`].
96///
97/// [`Element`]: crate::Element
98#[derive(
99 AsRef,
100 Clone,
101 Debug,
102 Display,
103 Eq,
104 From,
105 Hash,
106 Into,
107 Ord,
108 PartialEq,
109 PartialOrd,
110 RefCast,
111)]
112#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
113#[cfg_attr(feature = "serde", serde(transparent))]
114#[from(&str, String)]
115#[into(String)]
116#[repr(transparent)]
117pub struct Id(Box<str>);
118
119#[cfg(feature = "client-api")]
120impl From<medea_client_api_proto::MemberId> for Id {
121 fn from(id: medea_client_api_proto::MemberId) -> Self {
122 id.0.into()
123 }
124}
125
126/// [URI] used by a [`Member`] to connect to a media server via [Client API].
127///
128/// [Client API]: https://tinyurl.com/266y74tf
129/// [URI]: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
130#[derive(Clone, Debug)]
131pub struct Sid {
132 /// Public [URL] to establish [WebSocket] connections with.
133 ///
134 /// [URL]: https://en.wikipedia.org/wiki/URL
135 /// [WebSocket]: https://en.wikipedia.org/wiki/WebSocket
136 pub public_url: PublicUrl,
137
138 /// ID of the [`Room`] the [`Member`] participates in.
139 ///
140 /// [`Room`]: room::Room
141 pub room_id: room::Id,
142
143 /// ID of the [`Member`] who establishes [WebSocket] connections.
144 ///
145 /// [WebSocket]: https://en.wikipedia.org/wiki/WebSocket
146 pub member_id: Id,
147
148 /// [`PlainCredentials`] of the [`Member`] to authenticate him with.
149 pub creds: Option<PlainCredentials>,
150}
151
152impl Sid {
153 /// Renders the [URI] string of this [`Sid`]
154 ///
155 /// [URI]: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
156 // TODO: Return `SecretString` once `secrecy` crate allows to unwrap it:
157 // https://github.com/iqlusioninc/crates/issues/1182
158 #[must_use]
159 pub fn to_uri_string(&self) -> String {
160 let mut sid =
161 format!("{}/{}/{}", self.public_url, self.room_id, self.member_id);
162 if let Some(plain) = &self.creds {
163 write!(sid, "?token={}", plain.expose_str())
164 .expect("writing to `String` never fails");
165 }
166 sid
167 }
168}
169
170impl FromStr for Sid {
171 type Err = ParseSidError;
172
173 fn from_str(s: &str) -> Result<Self, Self::Err> {
174 let mut url = Url::parse(s)
175 .map_err(|e| ParseSidError::InvalidUrl(s.into(), e))?;
176
177 let creds = url.query_pairs().find_map(|(k, v)| {
178 (k.as_ref() == "token").then(|| v.as_ref().into())
179 });
180
181 url.set_fragment(None);
182 url.set_query(None);
183
184 let err_missing = || ParseSidError::MissingPaths(s.into());
185 let mut segments = url.path_segments().ok_or_else(err_missing)?.rev();
186 let member_id = segments.next().ok_or_else(err_missing)?.into();
187 let room_id = segments.next().ok_or_else(err_missing)?.into();
188
189 // Removes last two segments.
190 if let Ok(mut path) = url.path_segments_mut() {
191 _ = path.pop().pop();
192 }
193
194 Ok(Self { public_url: url.into(), room_id, member_id, creds })
195 }
196}
197
198/// Possible errors of parsing a [`Sid`].
199#[derive(Debug, Display, Error)]
200pub enum ParseSidError {
201 /// Some paths are missing in the provided [URI].
202 ///
203 /// `ws://localhost:8080/ws//qwerty`, for example.
204 ///
205 /// [URI]: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
206 #[display("Missing paths in URI: {_0}")]
207 MissingPaths(#[error(not(source))] Box<str>),
208
209 /// Error of parsing the provided [URI].
210 ///
211 /// [URI]: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
212 #[display("Cannot parse provided URI `{_0}`: {_1}")]
213 InvalidUrl(Box<str>, #[error(source)] url::ParseError),
214}
215
216/// Collection of [`Sid`]s to be used by [`Member`]s to connect to a media
217/// server via [Client API].
218///
219/// [Client API]: https://tinyurl.com/266y74tf
220pub type Sids = HashMap<Id, Sid>;
221
222/// Public [URL] of HTTP server exposing [Client API]. It's assumed that HTTP
223/// server can be reached via this [URL] externally.
224///
225/// This address is returned from [`ControlApi`] in a [`Sid`] and a client side
226/// should use this address to start its session.
227///
228/// [`ControlApi`]: crate::ControlApi
229/// [Client API]: https://tinyurl.com/266y74tf
230/// [URL]: https://en.wikipedia.org/wiki/URL
231#[derive(
232 AsRef,
233 Clone,
234 Debug,
235 Display,
236 Eq,
237 From,
238 FromStr,
239 Hash,
240 Into,
241 Ord,
242 PartialEq,
243 PartialOrd,
244)]
245#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
246#[cfg_attr(feature = "serde", serde(transparent))]
247pub struct PublicUrl(Url);
248
249/// Credentials of a [`Member`] media [`Element`] for its client side to
250/// authorize via [Client API] with.
251///
252/// [`Element`]: crate::Element
253/// [Client API]: https://tinyurl.com/266y74tf
254#[derive(Clone, Debug, Eq, From, PartialEq)]
255#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
256#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
257pub enum Credentials {
258 /// [Argon2] hash of credentials.
259 ///
260 /// [`Sid`] won't contain a `token` query parameter if
261 /// [`Credentials::Hash`] is used, so it should be appended manually on
262 /// a client side.
263 ///
264 /// [Argon2]: https://en.wikipedia.org/wiki/Argon2
265 #[from(ignore)]
266 Hash(Box<str>),
267
268 /// Plain text credentials.
269 Plain(PlainCredentials),
270}
271
272impl Credentials {
273 /// Generates new random [`Credentials::Plain`].
274 #[must_use]
275 pub fn random() -> Self {
276 use rand::{Rng as _, distr::Alphanumeric};
277
278 Self::Plain(
279 rand::rng()
280 .sample_iter(&Alphanumeric)
281 .take(32)
282 .map(char::from)
283 .collect::<String>()
284 .into(),
285 )
286 }
287}
288
289/// Plain [`Credentials`] returned in a [`Sid`].
290#[derive(AsRef, Clone, Debug)]
291#[cfg_attr(feature = "serde", derive(Deserialize))]
292#[cfg_attr(feature = "serde", serde(transparent))]
293pub struct PlainCredentials(SecretString);
294
295impl PlainCredentials {
296 /// Provides access to the underlying secret [`str`].
297 #[must_use]
298 pub fn expose_str(&self) -> &str {
299 self.0.expose_secret()
300 }
301}
302
303impl<T> From<T> for PlainCredentials
304where
305 T: Into<String>,
306{
307 fn from(value: T) -> Self {
308 Self(value.into().into())
309 }
310}
311
312impl Hash for PlainCredentials {
313 fn hash<H: Hasher>(&self, state: &mut H) {
314 self.expose_str().hash(state);
315 }
316}
317
318impl Eq for PlainCredentials {}
319
320impl PartialEq for PlainCredentials {
321 fn eq(&self, other: &Self) -> bool {
322 use subtle::ConstantTimeEq as _;
323
324 self.expose_str().as_bytes().ct_eq(other.expose_str().as_bytes()).into()
325 }
326}
327
328#[cfg(feature = "serde")]
329impl Serialize for PlainCredentials {
330 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
331 where
332 S: Serializer,
333 {
334 self.0.expose_secret().serialize(serializer)
335 }
336}