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;
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::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(Box::new(ev.deserialize()?))
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("Matrix client build error: {0}")]
159    Client(#[from] ClientBuildError),
160    /// An error when using the client.
161    #[error("Matrix client restoration error: {0}")]
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        // This only enables room keys backup and not recovery, which would leave us in an awkward
215        // state, because we want both to be enabled at the same time.
216        auto_enable_backups: false,
217    };
218
219    let mut client_builder = Client::builder()
220        .homeserver_url(homeserver)
221        .sqlite_store_with_cache_path(data_path, cache_path, Some(&passphrase))
222        // force_auth option to solve an issue with some servers configuration to require
223        // auth for profiles:
224        // https://gitlab.gnome.org/World/fractal/-/issues/934
225        .request_config(RequestConfig::new().retry_limit(2).force_auth())
226        .with_encryption_settings(encryption_settings);
227
228    if has_refresh_token {
229        client_builder = client_builder.handle_refresh_tokens();
230    }
231
232    let client = client_builder.build().await?;
233
234    client.restore_session(session_data).await?;
235
236    Ok(client)
237}
238
239/// Find mentions in the given HTML string.
240///
241/// Returns a list of `(pill, mention_content)` tuples.
242pub(crate) fn find_html_mentions(html: &str, room: &Room) -> Vec<(Pill, StrTendril)> {
243    let mut mentions = Vec::new();
244    let html = Html::parse(html);
245
246    append_children_mentions(&mut mentions, html.children(), room);
247
248    mentions
249}
250
251/// Find mentions in the given child nodes and append them to the given list.
252fn append_children_mentions(
253    mentions: &mut Vec<(Pill, StrTendril)>,
254    children: Children,
255    room: &Room,
256) {
257    for node in children {
258        if let Some(mention) = node_as_mention(&node, room) {
259            mentions.push(mention);
260            continue;
261        }
262
263        append_children_mentions(mentions, node.children(), room);
264    }
265}
266
267/// Try to convert the given node to a mention.
268///
269/// This does not recurse into children.
270fn node_as_mention(node: &NodeRef, room: &Room) -> Option<(Pill, StrTendril)> {
271    // Mentions are links.
272    let MatrixElement::A(anchor) = node.as_element()?.to_matrix().element else {
273        return None;
274    };
275
276    // Mentions contain Matrix URIs.
277    let id = MatrixIdUri::try_from(anchor.href?).ok()?;
278
279    // Mentions contain one text child node.
280    let child = node.children().next()?;
281
282    if child.next_sibling().is_some() {
283        return None;
284    }
285
286    let content = child.as_text()?.borrow().clone();
287    let pill = id.into_pill(room)?;
288
289    Some((pill, content))
290}
291
292/// The textual representation of a room mention.
293pub(crate) const AT_ROOM: &str = "@room";
294
295/// Find `@room` in the given string.
296///
297/// This uses the same algorithm as the pushrules from the Matrix spec to detect
298/// it in the `body`.
299///
300/// Returns the position of the first match.
301pub(crate) fn find_at_room(s: &str) -> Option<usize> {
302    for (pos, _) in s.match_indices(AT_ROOM) {
303        let is_at_word_start = pos == 0 || s[..pos].ends_with(char_is_ascii_word_boundary);
304        if !is_at_word_start {
305            continue;
306        }
307
308        let pos_after_match = pos + 5;
309        let is_at_word_end = pos_after_match == s.len()
310            || s[pos_after_match..].starts_with(char_is_ascii_word_boundary);
311        if is_at_word_end {
312            return Some(pos);
313        }
314    }
315
316    None
317}
318
319/// Whether the given `char` is a word boundary, according to the Matrix spec.
320///
321/// A word boundary is any character not in the sets `[A-Z]`, `[a-z]`, `[0-9]`
322/// or `_`.
323fn char_is_ascii_word_boundary(c: char) -> bool {
324    !c.is_ascii_alphanumeric() && c != '_'
325}
326
327/// Compare two raw JSON sources.
328pub(crate) fn raw_eq<T, U>(lhs: Option<&Raw<T>>, rhs: Option<&Raw<U>>) -> bool {
329    let Some(lhs) = lhs else {
330        // They are equal only if both are `None`.
331        return rhs.is_none();
332    };
333    let Some(rhs) = rhs else {
334        // They cannot be equal.
335        return false;
336    };
337
338    lhs.json().get() == rhs.json().get()
339}
340
341/// A URI for a Matrix ID.
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub(crate) enum MatrixIdUri {
344    /// A room.
345    Room(MatrixRoomIdUri),
346    /// A user.
347    User(OwnedUserId),
348    /// An event.
349    Event(MatrixEventIdUri),
350}
351
352impl MatrixIdUri {
353    /// Constructs a `MatrixIdUri` from the given ID and servers list.
354    fn try_from_parts(id: MatrixId, via: &[OwnedServerName]) -> Result<Self, ()> {
355        let uri = match id {
356            MatrixId::Room(room_id) => Self::Room(MatrixRoomIdUri {
357                id: room_id.into(),
358                via: via.to_owned(),
359            }),
360            MatrixId::RoomAlias(room_alias) => Self::Room(MatrixRoomIdUri {
361                id: room_alias.into(),
362                via: via.to_owned(),
363            }),
364            MatrixId::User(user_id) => Self::User(user_id),
365            MatrixId::Event(room_id, event_id) => Self::Event(MatrixEventIdUri {
366                event_id,
367                room_uri: MatrixRoomIdUri {
368                    id: room_id,
369                    via: via.to_owned(),
370                },
371            }),
372            _ => return Err(()),
373        };
374
375        Ok(uri)
376    }
377
378    /// Try parsing a `&str` into a `MatrixIdUri`.
379    pub(crate) fn parse(s: &str) -> Result<Self, MatrixIdUriParseError> {
380        if let Ok(uri) = MatrixToUri::parse(s) {
381            return uri.try_into();
382        }
383
384        MatrixUri::parse(s)?.try_into()
385    }
386
387    /// Try to construct a [`Pill`] from this ID in the given room.
388    pub(crate) fn into_pill(self, room: &Room) -> Option<Pill> {
389        match self {
390            Self::Room(room_uri) => {
391                let session = room.session()?;
392
393                let pill =
394                    if let Some(uri_room) = session.room_list().get_by_identifier(&room_uri.id) {
395                        // We do not need to watch safety settings for local rooms, they will be
396                        // watched automatically.
397                        Pill::new(&uri_room, AvatarImageSafetySetting::None, None)
398                    } else {
399                        Pill::new(
400                            &session.remote_cache().room(room_uri),
401                            AvatarImageSafetySetting::MediaPreviews,
402                            Some(room.clone()),
403                        )
404                    };
405
406                Some(pill)
407            }
408            Self::User(user_id) => {
409                // We should have a strong reference to the list wherever we show a user pill,
410                // so we can use `get_or_create_members()`.
411                let user = room.get_or_create_members().get_or_create(user_id);
412
413                // We do not need to watch safety settings for users.
414                Some(Pill::new(&user, AvatarImageSafetySetting::None, None))
415            }
416            Self::Event(_) => None,
417        }
418    }
419
420    /// Get this ID as a `matrix:` URI.
421    pub(crate) fn as_matrix_uri(&self) -> MatrixUri {
422        match self {
423            MatrixIdUri::Room(room_uri) => match <&RoomId>::try_from(&*room_uri.id) {
424                Ok(room_id) => room_id.matrix_uri_via(room_uri.via.clone(), false),
425                Err(room_alias) => room_alias.matrix_uri(false),
426            },
427            MatrixIdUri::User(user_id) => user_id.matrix_uri(false),
428            MatrixIdUri::Event(event_uri) => {
429                let room_id = <&RoomId>::try_from(&*event_uri.room_uri.id)
430                    .expect("room alias should not be used to construct event URI");
431
432                room_id.matrix_event_uri_via(
433                    event_uri.event_id.clone(),
434                    event_uri.room_uri.via.clone(),
435                )
436            }
437        }
438    }
439}
440
441impl fmt::Display for MatrixIdUri {
442    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
443        self.as_matrix_uri().fmt(f)
444    }
445}
446
447impl TryFrom<&MatrixUri> for MatrixIdUri {
448    type Error = MatrixIdUriParseError;
449
450    fn try_from(uri: &MatrixUri) -> Result<Self, Self::Error> {
451        // We ignore the action, because we always offer to join a room or DM a user.
452        Self::try_from_parts(uri.id().clone(), uri.via())
453            .map_err(|()| MatrixIdUriParseError::UnsupportedId(uri.id().clone()))
454    }
455}
456
457impl TryFrom<MatrixUri> for MatrixIdUri {
458    type Error = MatrixIdUriParseError;
459
460    fn try_from(uri: MatrixUri) -> Result<Self, Self::Error> {
461        Self::try_from(&uri)
462    }
463}
464
465impl TryFrom<&MatrixToUri> for MatrixIdUri {
466    type Error = MatrixIdUriParseError;
467
468    fn try_from(uri: &MatrixToUri) -> Result<Self, Self::Error> {
469        Self::try_from_parts(uri.id().clone(), uri.via())
470            .map_err(|()| MatrixIdUriParseError::UnsupportedId(uri.id().clone()))
471    }
472}
473
474impl TryFrom<MatrixToUri> for MatrixIdUri {
475    type Error = MatrixIdUriParseError;
476
477    fn try_from(uri: MatrixToUri) -> Result<Self, Self::Error> {
478        Self::try_from(&uri)
479    }
480}
481
482impl FromStr for MatrixIdUri {
483    type Err = MatrixIdUriParseError;
484
485    fn from_str(s: &str) -> Result<Self, Self::Err> {
486        Self::parse(s)
487    }
488}
489
490impl TryFrom<&str> for MatrixIdUri {
491    type Error = MatrixIdUriParseError;
492
493    fn try_from(s: &str) -> Result<Self, Self::Error> {
494        Self::parse(s)
495    }
496}
497
498impl TryFrom<&AnchorUri> for MatrixIdUri {
499    type Error = MatrixIdUriParseError;
500
501    fn try_from(value: &AnchorUri) -> Result<Self, Self::Error> {
502        match value {
503            AnchorUri::Matrix(uri) => MatrixIdUri::try_from(uri),
504            AnchorUri::MatrixTo(uri) => MatrixIdUri::try_from(uri),
505            // The same error that should be returned by `parse()` when parsing a non-Matrix URI.
506            _ => Err(IdParseError::InvalidMatrixUri(MatrixUriError::WrongScheme).into()),
507        }
508    }
509}
510
511impl TryFrom<AnchorUri> for MatrixIdUri {
512    type Error = MatrixIdUriParseError;
513
514    fn try_from(value: AnchorUri) -> Result<Self, Self::Error> {
515        Self::try_from(&value)
516    }
517}
518
519impl StaticVariantType for MatrixIdUri {
520    fn static_variant_type() -> Cow<'static, glib::VariantTy> {
521        String::static_variant_type()
522    }
523}
524
525impl ToVariant for MatrixIdUri {
526    fn to_variant(&self) -> glib::Variant {
527        self.to_string().to_variant()
528    }
529}
530
531impl FromVariant for MatrixIdUri {
532    fn from_variant(variant: &glib::Variant) -> Option<Self> {
533        Self::parse(&variant.get::<String>()?).ok()
534    }
535}
536
537/// A URI for a Matrix room ID.
538#[derive(Debug, Clone, PartialEq, Eq)]
539pub(crate) struct MatrixRoomIdUri {
540    /// The room ID.
541    pub(crate) id: OwnedRoomOrAliasId,
542    /// Matrix servers usable to route a `RoomId`.
543    pub(crate) via: Vec<OwnedServerName>,
544}
545
546impl MatrixRoomIdUri {
547    /// Try parsing a `&str` into a `MatrixRoomIdUri`.
548    pub(crate) fn parse(s: &str) -> Option<MatrixRoomIdUri> {
549        MatrixIdUri::parse(s)
550            .ok()
551            .and_then(|uri| match uri {
552                MatrixIdUri::Room(room_uri) => Some(room_uri),
553                _ => None,
554            })
555            .or_else(|| RoomOrAliasId::parse(s).ok().map(Into::into))
556    }
557}
558
559impl From<OwnedRoomOrAliasId> for MatrixRoomIdUri {
560    fn from(id: OwnedRoomOrAliasId) -> Self {
561        Self {
562            id,
563            via: Vec::new(),
564        }
565    }
566}
567
568impl From<OwnedRoomId> for MatrixRoomIdUri {
569    fn from(value: OwnedRoomId) -> Self {
570        OwnedRoomOrAliasId::from(value).into()
571    }
572}
573
574impl From<OwnedRoomAliasId> for MatrixRoomIdUri {
575    fn from(value: OwnedRoomAliasId) -> Self {
576        OwnedRoomOrAliasId::from(value).into()
577    }
578}
579
580impl From<&MatrixRoomIdUri> for MatrixUri {
581    fn from(value: &MatrixRoomIdUri) -> Self {
582        match <&RoomId>::try_from(&*value.id) {
583            Ok(room_id) => room_id.matrix_uri_via(value.via.clone(), false),
584            Err(alias) => alias.matrix_uri(false),
585        }
586    }
587}
588
589/// A URI for a Matrix event ID.
590#[derive(Debug, Clone, PartialEq, Eq)]
591pub(crate) struct MatrixEventIdUri {
592    /// The event ID.
593    pub event_id: OwnedEventId,
594    /// The event's room ID URI.
595    pub room_uri: MatrixRoomIdUri,
596}
597
598/// Errors encountered when parsing a Matrix ID URI.
599#[derive(Debug, Clone, Error)]
600pub(crate) enum MatrixIdUriParseError {
601    /// Not a valid Matrix URI.
602    #[error(transparent)]
603    InvalidUri(#[from] IdParseError),
604    /// Unsupported Matrix ID.
605    #[error("unsupported Matrix ID: {0:?}")]
606    UnsupportedId(MatrixId),
607}
608
609/// Convert the given timestamp to a `GDateTime`.
610pub(crate) fn timestamp_to_date(ts: MilliSecondsSinceUnixEpoch) -> glib::DateTime {
611    seconds_since_unix_epoch_to_date(ts.as_secs().into())
612}
613
614/// Convert the given number of seconds since Unix EPOCH to a `GDateTime`.
615pub(crate) fn seconds_since_unix_epoch_to_date(secs: i64) -> glib::DateTime {
616    glib::DateTime::from_unix_utc(secs)
617        .and_then(|date| date.to_local())
618        .expect("constructing GDateTime from timestamp should work")
619}
620
621/// The data used as a cache key for messages.
622///
623/// This is used when there is no reliable way to detect if the content of a
624/// message changed. For example, the URI of a media file might change between a
625/// local echo and a remote echo, but we do not need to reload the media in this
626/// case, and we have no other way to know that both URIs point to the same
627/// file.
628#[derive(Debug, Clone, Default)]
629pub(crate) struct MessageCacheKey {
630    /// The transaction ID of the event.
631    ///
632    /// Local echo should keep its transaction ID after the message is sent, so
633    /// we do not need to reload the message if it did not change.
634    pub(crate) transaction_id: Option<OwnedTransactionId>,
635    /// The global ID of the event.
636    ///
637    /// Local echo that was sent and remote echo should have the same event ID,
638    /// so we do not need to reload the message if it did not change.
639    pub(crate) event_id: Option<OwnedEventId>,
640    /// Whether the message is edited.
641    ///
642    /// The message must be reloaded when it was edited.
643    pub(crate) is_edited: bool,
644}
645
646impl MessageCacheKey {
647    /// Whether the given new `MessageCacheKey` should trigger a reload of the
648    /// message compared to this one.
649    pub(crate) fn should_reload(&self, new: &MessageCacheKey) -> bool {
650        if new.is_edited {
651            return true;
652        }
653
654        let transaction_id_invalidated = self.transaction_id.is_none()
655            || new.transaction_id.is_none()
656            || self.transaction_id != new.transaction_id;
657        let event_id_invalidated =
658            self.event_id.is_none() || new.event_id.is_none() || self.event_id != new.event_id;
659
660        transaction_id_invalidated && event_id_invalidated
661    }
662}