fractal/utils/matrix/
mod.rs

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