Skip to main content

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