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}