fractal/utils/matrix/
mod.rs

1//! Collection of methods related to the Matrix specification.
2
3use std::{borrow::Cow, fmt, str::FromStr};
4
5use gettextrs::gettext;
6use gtk::{glib, prelude::*};
7use matrix_sdk::{
8    AuthSession, Client, ClientBuildError, SessionMeta, SessionTokens,
9    authentication::{
10        matrix::MatrixSession,
11        oauth::{OAuthSession, UserSession},
12    },
13    config::RequestConfig,
14    deserialized_responses::RawAnySyncOrStrippedTimelineEvent,
15    encryption::{BackupDownloadStrategy, EncryptionSettings},
16};
17use ruma::{
18    EventId, IdParseError, MatrixToUri, MatrixUri, MatrixUriError, MilliSecondsSinceUnixEpoch,
19    OwnedEventId, OwnedRoomAliasId, OwnedRoomId, OwnedRoomOrAliasId, OwnedServerName, OwnedUserId,
20    RoomId, RoomOrAliasId, UserId,
21    events::{AnyStrippedStateEvent, AnySyncTimelineEvent},
22    html::{
23        Children, Html, NodeRef, StrTendril,
24        matrix::{AnchorUri, MatrixElement},
25    },
26    matrix_uri::MatrixId,
27    serde::Raw,
28};
29use thiserror::Error;
30use tracing::error;
31
32pub(crate) mod ext_traits;
33mod media_message;
34
35pub(crate) use self::media_message::*;
36use crate::{
37    components::{AvatarImageSafetySetting, Pill},
38    prelude::*,
39    secret::StoredSession,
40    session::model::Room,
41};
42
43/// The result of a password validation.
44#[derive(Debug, Default, Clone, Copy)]
45#[allow(clippy::struct_excessive_bools)]
46pub(crate) struct PasswordValidity {
47    /// Whether the password includes at least one lowercase letter.
48    pub(crate) has_lowercase: bool,
49    /// Whether the password includes at least one uppercase letter.
50    pub(crate) has_uppercase: bool,
51    /// Whether the password includes at least one number.
52    pub(crate) has_number: bool,
53    /// Whether the password includes at least one symbol.
54    pub(crate) has_symbol: bool,
55    /// Whether the password is at least 8 characters long.
56    pub(crate) has_length: bool,
57    /// The percentage of checks passed for the password, between 0 and 100.
58    ///
59    /// If progress is 100, the password is valid.
60    pub(crate) progress: u32,
61}
62
63impl PasswordValidity {
64    pub fn new() -> Self {
65        Self::default()
66    }
67}
68
69/// Validate a password according to the Matrix specification.
70///
71/// A password should include a lower-case letter, an upper-case letter, a
72/// number and a symbol and be at a minimum 8 characters in length.
73///
74/// See: <https://spec.matrix.org/v1.1/client-server-api/#notes-on-password-management>
75pub(crate) fn validate_password(password: &str) -> PasswordValidity {
76    let mut validity = PasswordValidity::new();
77
78    for char in password.chars() {
79        if char.is_numeric() {
80            validity.has_number = true;
81        } else if char.is_lowercase() {
82            validity.has_lowercase = true;
83        } else if char.is_uppercase() {
84            validity.has_uppercase = true;
85        } else {
86            validity.has_symbol = true;
87        }
88    }
89
90    validity.has_length = password.len() >= 8;
91
92    let mut passed = 0;
93    if validity.has_number {
94        passed += 1;
95    }
96    if validity.has_lowercase {
97        passed += 1;
98    }
99    if validity.has_uppercase {
100        passed += 1;
101    }
102    if validity.has_symbol {
103        passed += 1;
104    }
105    if validity.has_length {
106        passed += 1;
107    }
108    validity.progress = passed * 100 / 5;
109
110    validity
111}
112
113/// An deserialized event received in a sync response.
114#[derive(Debug, Clone)]
115pub(crate) enum AnySyncOrStrippedTimelineEvent {
116    /// An event from a joined or left room.
117    Sync(Box<AnySyncTimelineEvent>),
118    /// An event from an invited room.
119    Stripped(Box<AnyStrippedStateEvent>),
120}
121
122impl AnySyncOrStrippedTimelineEvent {
123    /// Deserialize the given raw event.
124    pub(crate) fn from_raw(
125        raw: &RawAnySyncOrStrippedTimelineEvent,
126    ) -> Result<Self, serde_json::Error> {
127        let ev = match raw {
128            RawAnySyncOrStrippedTimelineEvent::Sync(ev) => Self::Sync(ev.deserialize()?.into()),
129            RawAnySyncOrStrippedTimelineEvent::Stripped(ev) => {
130                Self::Stripped(ev.deserialize()?.into())
131            }
132        };
133
134        Ok(ev)
135    }
136
137    /// The sender of the event.
138    pub(crate) fn sender(&self) -> &UserId {
139        match self {
140            AnySyncOrStrippedTimelineEvent::Sync(ev) => ev.sender(),
141            AnySyncOrStrippedTimelineEvent::Stripped(ev) => ev.sender(),
142        }
143    }
144
145    /// The ID of the event, if it's not a stripped state event.
146    pub(crate) fn event_id(&self) -> Option<&EventId> {
147        match self {
148            AnySyncOrStrippedTimelineEvent::Sync(ev) => Some(ev.event_id()),
149            AnySyncOrStrippedTimelineEvent::Stripped(_) => None,
150        }
151    }
152}
153
154/// All errors that can occur when setting up the Matrix client.
155#[derive(Error, Debug)]
156pub(crate) enum ClientSetupError {
157    /// An error when building the client.
158    #[error(transparent)]
159    Client(#[from] ClientBuildError),
160    /// An error when using the client.
161    #[error(transparent)]
162    Sdk(#[from] matrix_sdk::Error),
163    /// An error creating the unique local ID of the session.
164    #[error("Could not generate unique session ID")]
165    NoSessionId,
166    /// An error accessing the session tokens.
167    #[error("Could not access session tokens")]
168    NoSessionTokens,
169}
170
171impl UserFacingError for ClientSetupError {
172    fn to_user_facing(&self) -> String {
173        match self {
174            Self::Client(err) => err.to_user_facing(),
175            Self::Sdk(err) => err.to_user_facing(),
176            Self::NoSessionId => gettext("Could not generate unique session ID"),
177            Self::NoSessionTokens => gettext("Could not access the session tokens"),
178        }
179    }
180}
181
182/// Create a [`Client`] with the given stored session.
183pub(crate) async fn client_with_stored_session(
184    session: StoredSession,
185    tokens: SessionTokens,
186) -> Result<Client, ClientSetupError> {
187    let has_refresh_token = tokens.refresh_token.is_some();
188    let data_path = session.data_path();
189    let cache_path = session.cache_path();
190
191    let StoredSession {
192        homeserver,
193        user_id,
194        device_id,
195        passphrase,
196        client_id,
197        ..
198    } = session;
199
200    let meta = SessionMeta { user_id, device_id };
201    let session_data: AuthSession = if let Some(client_id) = client_id {
202        OAuthSession {
203            user: UserSession { meta, tokens },
204            client_id,
205        }
206        .into()
207    } else {
208        MatrixSession { meta, tokens }.into()
209    };
210
211    let encryption_settings = EncryptionSettings {
212        auto_enable_cross_signing: true,
213        backup_download_strategy: BackupDownloadStrategy::AfterDecryptionFailure,
214        auto_enable_backups: true,
215    };
216
217    let mut client_builder = Client::builder()
218        .homeserver_url(homeserver)
219        .sqlite_store_with_cache_path(data_path, cache_path, Some(&passphrase))
220        // force_auth option to solve an issue with some servers configuration to require
221        // auth for profiles:
222        // https://gitlab.gnome.org/World/fractal/-/issues/934
223        .request_config(RequestConfig::new().retry_limit(2).force_auth())
224        .with_encryption_settings(encryption_settings);
225
226    if has_refresh_token {
227        client_builder = client_builder.handle_refresh_tokens();
228    }
229
230    let client = client_builder.build().await?;
231
232    client.restore_session(session_data).await?;
233
234    Ok(client)
235}
236
237/// Find mentions in the given HTML string.
238///
239/// Returns a list of `(pill, mention_content)` tuples.
240pub(crate) fn find_html_mentions(html: &str, room: &Room) -> Vec<(Pill, StrTendril)> {
241    let mut mentions = Vec::new();
242    let html = Html::parse(html);
243
244    append_children_mentions(&mut mentions, html.children(), room);
245
246    mentions
247}
248
249/// Find mentions in the given child nodes and append them to the given list.
250fn append_children_mentions(
251    mentions: &mut Vec<(Pill, StrTendril)>,
252    children: Children,
253    room: &Room,
254) {
255    for node in children {
256        if let Some(mention) = node_as_mention(&node, room) {
257            mentions.push(mention);
258            continue;
259        }
260
261        append_children_mentions(mentions, node.children(), room);
262    }
263}
264
265/// Try to convert the given node to a mention.
266///
267/// This does not recurse into children.
268fn node_as_mention(node: &NodeRef, room: &Room) -> Option<(Pill, StrTendril)> {
269    // Mentions are links.
270    let MatrixElement::A(anchor) = node.as_element()?.to_matrix().element else {
271        return None;
272    };
273
274    // Mentions contain Matrix URIs.
275    let id = MatrixIdUri::try_from(anchor.href?).ok()?;
276
277    // Mentions contain one text child node.
278    let child = node.children().next()?;
279
280    if child.next_sibling().is_some() {
281        return None;
282    }
283
284    let content = child.as_text()?.borrow().clone();
285    let pill = id.into_pill(room)?;
286
287    Some((pill, content))
288}
289
290/// The textual representation of a room mention.
291pub(crate) const AT_ROOM: &str = "@room";
292
293/// Find `@room` in the given string.
294///
295/// This uses the same algorithm as the pushrules from the Matrix spec to detect
296/// it in the `body`.
297///
298/// Returns the position of the first match.
299pub(crate) fn find_at_room(s: &str) -> Option<usize> {
300    for (pos, _) in s.match_indices(AT_ROOM) {
301        let is_at_word_start = pos == 0 || s[..pos].ends_with(char_is_ascii_word_boundary);
302        if !is_at_word_start {
303            continue;
304        }
305
306        let pos_after_match = pos + 5;
307        let is_at_word_end = pos_after_match == s.len()
308            || s[pos_after_match..].starts_with(char_is_ascii_word_boundary);
309        if is_at_word_end {
310            return Some(pos);
311        }
312    }
313
314    None
315}
316
317/// Whether the given `char` is a word boundary, according to the Matrix spec.
318///
319/// A word boundary is any character not in the sets `[A-Z]`, `[a-z]`, `[0-9]`
320/// or `_`.
321fn char_is_ascii_word_boundary(c: char) -> bool {
322    !c.is_ascii_alphanumeric() && c != '_'
323}
324
325/// Compare two raw JSON sources.
326pub(crate) fn raw_eq<T, U>(lhs: Option<&Raw<T>>, rhs: Option<&Raw<U>>) -> bool {
327    let Some(lhs) = lhs else {
328        // They are equal only if both are `None`.
329        return rhs.is_none();
330    };
331    let Some(rhs) = rhs else {
332        // They cannot be equal.
333        return false;
334    };
335
336    lhs.json().get() == rhs.json().get()
337}
338
339/// A URI for a Matrix ID.
340#[derive(Debug, Clone, PartialEq, Eq)]
341pub(crate) enum MatrixIdUri {
342    /// A room.
343    Room(MatrixRoomIdUri),
344    /// A user.
345    User(OwnedUserId),
346    /// An event.
347    Event(MatrixEventIdUri),
348}
349
350impl MatrixIdUri {
351    /// Constructs a `MatrixIdUri` from the given ID and servers list.
352    fn try_from_parts(id: MatrixId, via: &[OwnedServerName]) -> Result<Self, ()> {
353        let uri = match id {
354            MatrixId::Room(room_id) => Self::Room(MatrixRoomIdUri {
355                id: room_id.into(),
356                via: via.to_owned(),
357            }),
358            MatrixId::RoomAlias(room_alias) => Self::Room(MatrixRoomIdUri {
359                id: room_alias.into(),
360                via: via.to_owned(),
361            }),
362            MatrixId::User(user_id) => Self::User(user_id),
363            MatrixId::Event(room_id, event_id) => Self::Event(MatrixEventIdUri {
364                event_id,
365                room_uri: MatrixRoomIdUri {
366                    id: room_id,
367                    via: via.to_owned(),
368                },
369            }),
370            _ => return Err(()),
371        };
372
373        Ok(uri)
374    }
375
376    /// Try parsing a `&str` into a `MatrixIdUri`.
377    pub(crate) fn parse(s: &str) -> Result<Self, MatrixIdUriParseError> {
378        if let Ok(uri) = MatrixToUri::parse(s) {
379            return uri.try_into();
380        }
381
382        MatrixUri::parse(s)?.try_into()
383    }
384
385    /// Try to construct a [`Pill`] from this ID in the given room.
386    pub(crate) fn into_pill(self, room: &Room) -> Option<Pill> {
387        match self {
388            Self::Room(room_uri) => {
389                let session = room.session()?;
390
391                let pill =
392                    if let Some(uri_room) = session.room_list().get_by_identifier(&room_uri.id) {
393                        // We do not need to watch safety settings for local rooms, they will be
394                        // watched automatically.
395                        Pill::new(&uri_room, AvatarImageSafetySetting::None, None)
396                    } else {
397                        Pill::new(
398                            &session.remote_cache().room(room_uri),
399                            AvatarImageSafetySetting::MediaPreviews,
400                            Some(room.clone()),
401                        )
402                    };
403
404                Some(pill)
405            }
406            Self::User(user_id) => {
407                // We should have a strong reference to the list wherever we show a user pill,
408                // so we can use `get_or_create_members()`.
409                let user = room.get_or_create_members().get_or_create(user_id);
410
411                // We do not need to watch safety settings for users.
412                Some(Pill::new(&user, AvatarImageSafetySetting::None, None))
413            }
414            Self::Event(_) => None,
415        }
416    }
417
418    /// Get this ID as a `matrix:` URI.
419    pub(crate) fn as_matrix_uri(&self) -> MatrixUri {
420        match self {
421            MatrixIdUri::Room(room_uri) => match <&RoomId>::try_from(&*room_uri.id) {
422                Ok(room_id) => room_id.matrix_uri_via(room_uri.via.clone(), false),
423                Err(room_alias) => room_alias.matrix_uri(false),
424            },
425            MatrixIdUri::User(user_id) => user_id.matrix_uri(false),
426            MatrixIdUri::Event(event_uri) => {
427                let room_id = <&RoomId>::try_from(&*event_uri.room_uri.id)
428                    .expect("room alias should not be used to construct event URI");
429
430                room_id.matrix_event_uri_via(
431                    event_uri.event_id.clone(),
432                    event_uri.room_uri.via.clone(),
433                )
434            }
435        }
436    }
437}
438
439impl fmt::Display for MatrixIdUri {
440    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
441        self.as_matrix_uri().fmt(f)
442    }
443}
444
445impl TryFrom<&MatrixUri> for MatrixIdUri {
446    type Error = MatrixIdUriParseError;
447
448    fn try_from(uri: &MatrixUri) -> Result<Self, Self::Error> {
449        // We ignore the action, because we always offer to join a room or DM a user.
450        Self::try_from_parts(uri.id().clone(), uri.via())
451            .map_err(|()| MatrixIdUriParseError::UnsupportedId(uri.id().clone()))
452    }
453}
454
455impl TryFrom<MatrixUri> for MatrixIdUri {
456    type Error = MatrixIdUriParseError;
457
458    fn try_from(uri: MatrixUri) -> Result<Self, Self::Error> {
459        Self::try_from(&uri)
460    }
461}
462
463impl TryFrom<&MatrixToUri> for MatrixIdUri {
464    type Error = MatrixIdUriParseError;
465
466    fn try_from(uri: &MatrixToUri) -> Result<Self, Self::Error> {
467        Self::try_from_parts(uri.id().clone(), uri.via())
468            .map_err(|()| MatrixIdUriParseError::UnsupportedId(uri.id().clone()))
469    }
470}
471
472impl TryFrom<MatrixToUri> for MatrixIdUri {
473    type Error = MatrixIdUriParseError;
474
475    fn try_from(uri: MatrixToUri) -> Result<Self, Self::Error> {
476        Self::try_from(&uri)
477    }
478}
479
480impl FromStr for MatrixIdUri {
481    type Err = MatrixIdUriParseError;
482
483    fn from_str(s: &str) -> Result<Self, Self::Err> {
484        Self::parse(s)
485    }
486}
487
488impl TryFrom<&str> for MatrixIdUri {
489    type Error = MatrixIdUriParseError;
490
491    fn try_from(s: &str) -> Result<Self, Self::Error> {
492        Self::parse(s)
493    }
494}
495
496impl TryFrom<&AnchorUri> for MatrixIdUri {
497    type Error = MatrixIdUriParseError;
498
499    fn try_from(value: &AnchorUri) -> Result<Self, Self::Error> {
500        match value {
501            AnchorUri::Matrix(uri) => MatrixIdUri::try_from(uri),
502            AnchorUri::MatrixTo(uri) => MatrixIdUri::try_from(uri),
503            // The same error that should be returned by `parse()` when parsing a non-Matrix URI.
504            _ => Err(IdParseError::InvalidMatrixUri(MatrixUriError::WrongScheme).into()),
505        }
506    }
507}
508
509impl TryFrom<AnchorUri> for MatrixIdUri {
510    type Error = MatrixIdUriParseError;
511
512    fn try_from(value: AnchorUri) -> Result<Self, Self::Error> {
513        Self::try_from(&value)
514    }
515}
516
517impl StaticVariantType for MatrixIdUri {
518    fn static_variant_type() -> Cow<'static, glib::VariantTy> {
519        String::static_variant_type()
520    }
521}
522
523impl ToVariant for MatrixIdUri {
524    fn to_variant(&self) -> glib::Variant {
525        self.to_string().to_variant()
526    }
527}
528
529impl FromVariant for MatrixIdUri {
530    fn from_variant(variant: &glib::Variant) -> Option<Self> {
531        Self::parse(&variant.get::<String>()?).ok()
532    }
533}
534
535/// A URI for a Matrix room ID.
536#[derive(Debug, Clone, PartialEq, Eq)]
537pub(crate) struct MatrixRoomIdUri {
538    /// The room ID.
539    pub(crate) id: OwnedRoomOrAliasId,
540    /// Matrix servers usable to route a `RoomId`.
541    pub(crate) via: Vec<OwnedServerName>,
542}
543
544impl MatrixRoomIdUri {
545    /// Try parsing a `&str` into a `MatrixRoomIdUri`.
546    pub(crate) fn parse(s: &str) -> Option<MatrixRoomIdUri> {
547        MatrixIdUri::parse(s)
548            .ok()
549            .and_then(|uri| match uri {
550                MatrixIdUri::Room(room_uri) => Some(room_uri),
551                _ => None,
552            })
553            .or_else(|| RoomOrAliasId::parse(s).ok().map(Into::into))
554    }
555}
556
557impl From<OwnedRoomOrAliasId> for MatrixRoomIdUri {
558    fn from(id: OwnedRoomOrAliasId) -> Self {
559        Self {
560            id,
561            via: Vec::new(),
562        }
563    }
564}
565
566impl From<OwnedRoomId> for MatrixRoomIdUri {
567    fn from(value: OwnedRoomId) -> Self {
568        OwnedRoomOrAliasId::from(value).into()
569    }
570}
571
572impl From<OwnedRoomAliasId> for MatrixRoomIdUri {
573    fn from(value: OwnedRoomAliasId) -> Self {
574        OwnedRoomOrAliasId::from(value).into()
575    }
576}
577
578impl From<&MatrixRoomIdUri> for MatrixUri {
579    fn from(value: &MatrixRoomIdUri) -> Self {
580        match <&RoomId>::try_from(&*value.id) {
581            Ok(room_id) => room_id.matrix_uri_via(value.via.clone(), false),
582            Err(alias) => alias.matrix_uri(false),
583        }
584    }
585}
586
587/// A URI for a Matrix event ID.
588#[derive(Debug, Clone, PartialEq, Eq)]
589pub(crate) struct MatrixEventIdUri {
590    /// The event ID.
591    pub event_id: OwnedEventId,
592    /// The event's room ID URI.
593    pub room_uri: MatrixRoomIdUri,
594}
595
596/// Errors encountered when parsing a Matrix ID URI.
597#[derive(Debug, Clone, Error)]
598pub(crate) enum MatrixIdUriParseError {
599    /// Not a valid Matrix URI.
600    #[error(transparent)]
601    InvalidUri(#[from] IdParseError),
602    /// Unsupported Matrix ID.
603    #[error("unsupported Matrix ID: {0:?}")]
604    UnsupportedId(MatrixId),
605}
606
607/// Convert the given timestamp to a `GDateTime`.
608pub(crate) fn timestamp_to_date(ts: MilliSecondsSinceUnixEpoch) -> glib::DateTime {
609    seconds_since_unix_epoch_to_date(ts.as_secs().into())
610}
611
612/// Convert the given number of seconds since Unix EPOCH to a `GDateTime`.
613pub(crate) fn seconds_since_unix_epoch_to_date(secs: i64) -> glib::DateTime {
614    glib::DateTime::from_unix_utc(secs)
615        .and_then(|date| date.to_local())
616        .expect("constructing GDateTime from timestamp should work")
617}