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            #[expect(clippy::expect_used, reason = "never fails")]
164            write!(sid, "?token={}", plain.expose_str())
165                .expect("writing to `String` never fails");
166        }
167        sid
168    }
169}
170
171impl FromStr for Sid {
172    type Err = ParseSidError;
173
174    fn from_str(s: &str) -> Result<Self, Self::Err> {
175        let mut url = Url::parse(s)
176            .map_err(|e| ParseSidError::InvalidUrl(s.into(), e))?;
177
178        let creds = url.query_pairs().find_map(|(k, v)| {
179            (k.as_ref() == "token").then(|| v.as_ref().into())
180        });
181
182        url.set_fragment(None);
183        url.set_query(None);
184
185        let err_missing = || ParseSidError::MissingPaths(s.into());
186        let mut segments = url.path_segments().ok_or_else(err_missing)?.rev();
187        let member_id = segments.next().ok_or_else(err_missing)?.into();
188        let room_id = segments.next().ok_or_else(err_missing)?.into();
189
190        // Removes last two segments.
191        if let Ok(mut path) = url.path_segments_mut() {
192            _ = path.pop().pop();
193        }
194
195        Ok(Self { public_url: url.into(), room_id, member_id, creds })
196    }
197}
198
199/// Possible errors of parsing a [`Sid`].
200#[derive(Debug, Display, Error)]
201pub enum ParseSidError {
202    /// Some paths are missing in the provided [URI].
203    ///
204    /// `ws://localhost:8080/ws//qwerty`, for example.
205    ///
206    /// [URI]: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
207    #[display("Missing paths in URI: {_0}")]
208    MissingPaths(#[error(not(source))] Box<str>),
209
210    /// Error of parsing the provided [URI].
211    ///
212    /// [URI]: https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
213    #[display("Cannot parse provided URI `{_0}`: {_1}")]
214    InvalidUrl(Box<str>, #[error(source)] url::ParseError),
215}
216
217/// Collection of [`Sid`]s to be used by [`Member`]s to connect to a media
218/// server via [Client API].
219///
220/// [Client API]: https://tinyurl.com/266y74tf
221pub type Sids = HashMap<Id, Sid>;
222
223/// Public [URL] of HTTP server exposing [Client API]. It's assumed that HTTP
224/// server can be reached via this [URL] externally.
225///
226/// This address is returned from [`ControlApi`] in a [`Sid`] and a client side
227/// should use this address to start its session.
228///
229/// [`ControlApi`]: crate::ControlApi
230/// [Client API]: https://tinyurl.com/266y74tf
231/// [URL]: https://en.wikipedia.org/wiki/URL
232#[derive(
233    AsRef,
234    Clone,
235    Debug,
236    Display,
237    Eq,
238    From,
239    FromStr,
240    Hash,
241    Into,
242    Ord,
243    PartialEq,
244    PartialOrd,
245)]
246#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
247#[cfg_attr(feature = "serde", serde(transparent))]
248pub struct PublicUrl(Url);
249
250/// Credentials of a [`Member`] media [`Element`] for its client side to
251/// authorize via [Client API] with.
252///
253/// [`Element`]: crate::Element
254/// [Client API]: https://tinyurl.com/266y74tf
255#[derive(Clone, Debug, Eq, From, PartialEq)]
256#[cfg_attr(feature = "serde", derive(Deserialize, Serialize))]
257#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
258pub enum Credentials {
259    /// [Argon2] hash of credentials.
260    ///
261    /// [`Sid`] won't contain a `token` query parameter if
262    /// [`Credentials::Hash`] is used, so it should be appended manually on
263    /// a client side.
264    ///
265    /// [Argon2]: https://en.wikipedia.org/wiki/Argon2
266    #[from(ignore)]
267    Hash(Box<str>),
268
269    /// Plain text credentials.
270    Plain(PlainCredentials),
271}
272
273impl Credentials {
274    /// Generates new random [`Credentials::Plain`].
275    #[must_use]
276    pub fn random() -> Self {
277        use rand::{Rng as _, distr::Alphanumeric};
278
279        Self::Plain(
280            rand::rng()
281                .sample_iter(&Alphanumeric)
282                .take(32)
283                .map(char::from)
284                .collect::<String>()
285                .into(),
286        )
287    }
288}
289
290/// Plain [`Credentials`] returned in a [`Sid`].
291#[derive(AsRef, Clone, Debug)]
292#[cfg_attr(feature = "serde", derive(Deserialize))]
293#[cfg_attr(feature = "serde", serde(transparent))]
294pub struct PlainCredentials(SecretString);
295
296impl PlainCredentials {
297    /// Provides access to the underlying secret [`str`].
298    #[must_use]
299    pub fn expose_str(&self) -> &str {
300        self.0.expose_secret()
301    }
302}
303
304impl<T> From<T> for PlainCredentials
305where
306    T: Into<String>,
307{
308    fn from(value: T) -> Self {
309        Self(value.into().into())
310    }
311}
312
313impl Hash for PlainCredentials {
314    fn hash<H: Hasher>(&self, state: &mut H) {
315        self.expose_str().hash(state);
316    }
317}
318
319impl Eq for PlainCredentials {}
320
321impl PartialEq for PlainCredentials {
322    fn eq(&self, other: &Self) -> bool {
323        use subtle::ConstantTimeEq as _;
324
325        self.expose_str().as_bytes().ct_eq(other.expose_str().as_bytes()).into()
326    }
327}
328
329#[cfg(feature = "serde")]
330impl Serialize for PlainCredentials {
331    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
332    where
333        S: Serializer,
334    {
335        self.0.expose_secret().serialize(serializer)
336    }
337}