ruma_events/room/message/
url_preview.rs

1use serde::{Deserialize, Serialize};
2
3use crate::room::{EncryptedFile, OwnedMxcUri, UInt};
4
5/// The Source of the PreviewImage.
6#[derive(Clone, Debug, Deserialize, Serialize)]
7#[allow(clippy::exhaustive_enums)]
8pub enum PreviewImageSource {
9    /// Source of the PreviewImage as encrypted file data
10    #[serde(rename = "beeper:image:encryption", alias = "matrix:image:encryption")]
11    EncryptedImage(EncryptedFile),
12
13    /// Source of the PreviewImage as a simple MxcUri
14    #[serde(rename = "og:image", alias = "og:image:url")]
15    Url(OwnedMxcUri),
16}
17
18/// Metadata and [`PreviewImageSource`] of an [`UrlPreview`] image.
19///
20/// Modelled after [OpenGraph Image Properties](https://ogp.me/#structured).
21#[derive(Clone, Debug, Deserialize, Serialize)]
22#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
23pub struct PreviewImage {
24    /// Source information for the image.
25    #[serde(flatten)]
26    pub source: PreviewImageSource,
27
28    /// The size of the image in bytes.
29    #[serde(
30        rename = "matrix:image:size",
31        alias = "og:image:size",
32        skip_serializing_if = "Option::is_none"
33    )]
34    pub size: Option<UInt>,
35
36    /// The width of the image in pixels.
37    #[serde(rename = "og:image:width", skip_serializing_if = "Option::is_none")]
38    pub width: Option<UInt>,
39
40    /// The height of the image in pixels.
41    #[serde(rename = "og:image:height", skip_serializing_if = "Option::is_none")]
42    pub height: Option<UInt>,
43
44    /// The mime_type of the image.
45    #[serde(rename = "og:image:type", skip_serializing_if = "Option::is_none")]
46    pub mimetype: Option<String>,
47}
48
49impl PreviewImage {
50    /// Construct a PreviewImage with the given [`OwnedMxcUri`] as the source.
51    pub fn plain(url: OwnedMxcUri) -> Self {
52        Self::with_image(PreviewImageSource::Url(url))
53    }
54
55    /// Construct the PreviewImage for the given [`EncryptedFile`] as the source.
56    pub fn encrypted(file: EncryptedFile) -> Self {
57        Self::with_image(PreviewImageSource::EncryptedImage(file))
58    }
59
60    fn with_image(source: PreviewImageSource) -> Self {
61        PreviewImage { source, size: None, width: None, height: None, mimetype: None }
62    }
63}
64
65/// Preview Information for a URL matched in the message's text, according to
66/// [MSC 4095](https://github.com/matrix-org/matrix-spec-proposals/pull/4095).
67#[derive(Clone, Debug, Deserialize, Serialize)]
68#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
69pub struct UrlPreview {
70    /// The url this was matching on.
71    #[serde(alias = "matrix:matched_url")]
72    pub matched_url: Option<String>,
73
74    /// Canonical URL according to open graph data.
75    #[serde(rename = "og:url", skip_serializing_if = "Option::is_none")]
76    pub url: Option<String>,
77
78    /// Title to use for the preview.
79    #[serde(rename = "og:title", skip_serializing_if = "Option::is_none")]
80    pub title: Option<String>,
81
82    /// Description to use for the preview.
83    #[serde(rename = "og:description", skip_serializing_if = "Option::is_none")]
84    pub description: Option<String>,
85
86    /// Metadata of a preview image if given.
87    #[serde(flatten, skip_serializing_if = "Option::is_none")]
88    pub image: Option<PreviewImage>,
89}
90
91impl UrlPreview {
92    /// Construct an preview for a matched_url.
93    pub fn matched_url(matched_url: String) -> Self {
94        UrlPreview {
95            matched_url: Some(matched_url),
96            url: None,
97            image: None,
98            description: None,
99            title: None,
100        }
101    }
102
103    /// Construct an preview for a canonical url.
104    pub fn canonical_url(url: String) -> Self {
105        UrlPreview {
106            matched_url: None,
107            url: Some(url),
108            image: None,
109            description: None,
110            title: None,
111        }
112    }
113
114    /// Whether this preview contains an actual preview or the users homeserver
115    /// should be asked for preview data instead.
116    pub fn contains_preview(&self) -> bool {
117        self.url.is_some()
118            || self.title.is_some()
119            || self.description.is_some()
120            || self.image.is_some()
121    }
122}
123
124#[cfg(test)]
125mod tests {
126    use std::collections::BTreeMap;
127
128    use assert_matches2::assert_matches;
129    use assign::assign;
130    use js_int::uint;
131    use ruma_common::{canonical_json::assert_to_canonical_json_eq, owned_mxc_uri, serde::Base64};
132    use ruma_events::room::message::{MessageType, RoomMessageEventContent};
133    use serde_json::{from_value as from_json_value, json};
134
135    use super::{super::text::TextMessageEventContent, *};
136    use crate::room::{EncryptedFile, JsonWebKey};
137
138    fn dummy_jwt() -> JsonWebKey {
139        JsonWebKey {
140            kty: "oct".to_owned(),
141            key_ops: vec!["encrypt".to_owned(), "decrypt".to_owned()],
142            alg: "A256CTR".to_owned(),
143            k: Base64::new(vec![0; 64]),
144            ext: true,
145        }
146    }
147
148    fn encrypted_file() -> EncryptedFile {
149        let mut hashes: BTreeMap<String, Base64> = BTreeMap::new();
150        hashes.insert("sha256".to_owned(), Base64::new(vec![1; 10]));
151        EncryptedFile {
152            url: owned_mxc_uri!("mxc://localhost/encryptedfile"),
153            key: dummy_jwt(),
154            iv: Base64::new(vec![1; 12]),
155            hashes,
156            v: "v2".to_owned(),
157        }
158    }
159
160    #[test]
161    fn serialize_preview_image() {
162        let preview =
163            PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO"));
164
165        assert_to_canonical_json_eq!(
166            preview,
167            json!({
168                "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
169            }),
170        );
171
172        let preview = PreviewImage::encrypted(encrypted_file());
173
174        assert_to_canonical_json_eq!(
175            preview,
176            json!({
177                "beeper:image:encryption": {
178                    "hashes" : {
179                        "sha256": "AQEBAQEBAQEBAQ",
180                    },
181                    "iv": "AQEBAQEBAQEBAQEB",
182                    "key": {
183                        "alg": "A256CTR",
184                        "ext": true,
185                        "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
186                        "key_ops": [
187                            "encrypt",
188                            "decrypt"
189                        ],
190                        "kty": "oct",
191                    },
192                    "v": "v2",
193                    "url": "mxc://localhost/encryptedfile",
194                },
195            }),
196        );
197    }
198
199    #[test]
200    fn serialize_room_message_with_url_preview() {
201        let preview_img =
202            PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO"));
203        let full_preview = assign!(UrlPreview::matched_url("https://matrix.org/".to_owned()), {image: Some(preview_img)});
204        let msg = MessageType::Text(assign!(TextMessageEventContent::plain("Test message"),  {
205            url_previews: Some(vec![full_preview])
206        }));
207
208        assert_to_canonical_json_eq!(
209            RoomMessageEventContent::new(msg),
210            json!({
211                "msgtype": "m.text",
212                "body": "Test message",
213                "com.beeper.linkpreviews": [
214                    {
215                        "matched_url": "https://matrix.org/",
216                        "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
217                    }
218                ],
219            }),
220        );
221    }
222
223    #[test]
224    fn serialize_room_message_with_url_preview_with_encrypted_image() {
225        let preview_img = PreviewImage::encrypted(encrypted_file());
226        let full_preview = assign!(UrlPreview::matched_url("https://matrix.org/".to_owned()), {
227            image: Some(preview_img),
228        });
229
230        let msg = MessageType::Text(assign!(TextMessageEventContent::plain("Test message"),  {
231            url_previews: Some(vec![full_preview])
232        }));
233
234        assert_to_canonical_json_eq!(
235            RoomMessageEventContent::new(msg),
236            json!({
237                "msgtype": "m.text",
238                "body": "Test message",
239                "com.beeper.linkpreviews": [
240                    {
241                        "matched_url": "https://matrix.org/",
242                        "beeper:image:encryption": {
243                            "hashes" : {
244                                "sha256": "AQEBAQEBAQEBAQ",
245                            },
246                            "iv": "AQEBAQEBAQEBAQEB",
247                            "key": {
248                                "alg": "A256CTR",
249                                "ext": true,
250                                "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
251                                "key_ops": [
252                                    "encrypt",
253                                    "decrypt"
254                                ],
255                                "kty": "oct",
256                            },
257                            "v": "v2",
258                            "url": "mxc://localhost/encryptedfile",
259                        },
260                    }
261                ],
262            }),
263        );
264    }
265
266    #[cfg(feature = "unstable-msc1767")]
267    #[test]
268    fn serialize_extensible_room_message_with_preview() {
269        use crate::message::MessageEventContent;
270
271        let preview_img = assign!(PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO")), {
272                height: Some(uint!(400)),
273                width: Some(uint!(800)),
274                mimetype: Some("image/jpeg".to_owned()),
275                size: Some(uint!(16588))
276        });
277        let full_preview = assign!(UrlPreview::matched_url("matrix.org/support".to_owned()), {
278                   image: Some(preview_img),
279                   url: Some("https://matrix.org/support/".to_owned()),
280                   title: Some("Support Matrix".to_owned()),
281                   description: Some("Matrix, the open protocol for secure decentralised communications".to_owned()),
282        });
283        let msg = assign!(MessageEventContent::plain("matrix.org/support"),  {
284            url_previews: Some(vec![full_preview])
285        });
286        assert_to_canonical_json_eq!(
287            msg,
288            json!({
289                "org.matrix.msc1767.text": [
290                    {"body": "matrix.org/support"}
291                ],
292                "com.beeper.linkpreviews": [
293                    {
294                        "matched_url": "matrix.org/support",
295                        "matrix:image:size": 16588,
296                        "og:description": "Matrix, the open protocol for secure decentralised communications",
297                        "og:image":"mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
298                        "og:image:height": 400,
299                        "og:image:type": "image/jpeg",
300                        "og:image:width": 800,
301                        "og:title": "Support Matrix",
302                        "og:url": "https://matrix.org/support/",
303                    }
304                ],
305            }),
306        );
307    }
308
309    #[test]
310    fn deserialize_regular_example() {
311        let normal_preview = json!({
312            "msgtype": "m.text",
313            "body": "https://matrix.org",
314            "m.url_previews": [
315                {
316                    "matrix:matched_url": "https://matrix.org",
317                    "matrix:image:size": 16588,
318                    "og:description": "Matrix, the open protocol for secure decentralised communications",
319                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
320                    "og:image:height": 400,
321                    "og:image:type": "image/jpeg",
322                    "og:image:width": 800,
323                    "og:title": "Matrix.org",
324                    "og:url": "https://matrix.org/"
325                }
326            ],
327            "m.mentions": {}
328        });
329
330        let message_with_preview: TextMessageEventContent =
331            from_json_value(normal_preview).unwrap();
332        let TextMessageEventContent { url_previews, .. } = message_with_preview;
333        let previews = url_previews.expect("No url previews found");
334        assert_eq!(previews.len(), 1);
335        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
336        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
337        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
338        assert_eq!(
339            description.as_ref().unwrap(),
340            "Matrix, the open protocol for secure decentralised communications"
341        );
342        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
343
344        // Check the preview image parsed:
345        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
346        assert_eq!(size.unwrap(), uint!(16588));
347        assert_matches!(source, PreviewImageSource::Url(url));
348        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
349        assert_eq!(height.unwrap(), uint!(400));
350        assert_eq!(width.unwrap(), uint!(800));
351        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
352    }
353
354    #[test]
355    fn deserialize_under_dev_prefix() {
356        let normal_preview = json!({
357            "msgtype": "m.text",
358            "body": "https://matrix.org",
359            "com.beeper.linkpreviews": [
360                {
361                    "matched_url": "https://matrix.org",
362                    "matrix:image:size": 16588,
363                    "og:description": "Matrix, the open protocol for secure decentralised communications",
364                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
365                    "og:image:height": 400,
366                    "og:image:type": "image/jpeg",
367                    "og:image:width": 800,
368                    "og:title": "Matrix.org",
369                    "og:url": "https://matrix.org/"
370                }
371            ],
372            "m.mentions": {}
373        });
374
375        let message_with_preview: TextMessageEventContent =
376            from_json_value(normal_preview).unwrap();
377        let TextMessageEventContent { url_previews, .. } = message_with_preview;
378        let previews = url_previews.expect("No url previews found");
379        assert_eq!(previews.len(), 1);
380        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
381        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
382        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
383        assert_eq!(
384            description.as_ref().unwrap(),
385            "Matrix, the open protocol for secure decentralised communications"
386        );
387        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
388
389        // Check the preview image parsed:
390        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
391        assert_eq!(size.unwrap(), uint!(16588));
392        assert_matches!(source, PreviewImageSource::Url(url));
393        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
394        assert_eq!(height.unwrap(), uint!(400));
395        assert_eq!(width.unwrap(), uint!(800));
396        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
397    }
398
399    #[test]
400    fn deserialize_example_no_previews() {
401        let normal_preview = json!({
402            "msgtype": "m.text",
403            "body": "https://matrix.org",
404            "m.url_previews": [],
405            "m.mentions": {}
406        });
407        let message_with_preview: TextMessageEventContent =
408            from_json_value(normal_preview).unwrap();
409        let TextMessageEventContent { url_previews, .. } = message_with_preview;
410        assert!(url_previews.clone().unwrap().is_empty(), "Unexpectedly found url previews");
411    }
412
413    #[test]
414    fn deserialize_example_empty_previews() {
415        let normal_preview = json!({
416            "msgtype": "m.text",
417            "body": "https://matrix.org",
418            "m.url_previews": [
419                { "matrix:matched_url": "https://matrix.org" }
420            ],
421            "m.mentions": {}
422        });
423
424        let message_with_preview: TextMessageEventContent =
425            from_json_value(normal_preview).unwrap();
426        let TextMessageEventContent { url_previews, .. } = message_with_preview;
427        let previews = url_previews.expect("No url previews found");
428        assert_eq!(previews.len(), 1);
429        let preview = previews.first().unwrap();
430        assert_eq!(preview.matched_url.as_ref().unwrap(), "https://matrix.org");
431        assert!(!preview.contains_preview());
432    }
433
434    #[test]
435    fn deserialize_encrypted_image_dev_example() {
436        let normal_preview = json!({
437            "msgtype": "m.text",
438            "body": "https://matrix.org",
439            "com.beeper.linkpreviews": [
440                {
441                    "matched_url": "https://matrix.org",
442                    "og:title": "Matrix.org",
443                    "og:url": "https://matrix.org/",
444                    "og:description": "Matrix, the open protocol for secure decentralised communications",
445                    "matrix:image:size": 16588,
446                    "og:image:height": 400,
447                    "og:image:type": "image/jpeg",
448                    "og:image:width": 800,
449                    "beeper:image:encryption": {
450                        "key": {
451                            "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
452                            "alg": "A256CTR",
453                            "ext": true,
454                            "kty": "oct",
455                            "key_ops": [
456                                "encrypt",
457                                "decrypt"
458                            ]
459                        },
460                        "iv": "AQEBAQEBAQEBAQEB",
461                        "hashes": {
462                            "sha256": "AQEBAQEBAQEBAQ"
463                        },
464                        "v": "v2",
465                        "url": "mxc://beeper.com/53207ac52ce3e2c722bb638987064bfdc0cc257b"
466                    }
467                }
468            ],
469            "m.mentions": {}
470        });
471
472        let message_with_preview: TextMessageEventContent =
473            from_json_value(normal_preview).unwrap();
474        let TextMessageEventContent { url_previews, .. } = message_with_preview;
475        let previews = url_previews.expect("No url previews found");
476        assert_eq!(previews.len(), 1);
477        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
478        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
479        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
480        assert_eq!(
481            description.as_ref().unwrap(),
482            "Matrix, the open protocol for secure decentralised communications"
483        );
484        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
485
486        // Check the preview image parsed:
487        let PreviewImage { size, height, width, mimetype, source } = image.as_ref().unwrap();
488
489        assert_eq!(size.unwrap(), uint!(16588));
490
491        assert_matches!(source, PreviewImageSource::EncryptedImage(encrypted_image));
492        assert_eq!(
493            encrypted_image.url.as_str(),
494            "mxc://beeper.com/53207ac52ce3e2c722bb638987064bfdc0cc257b"
495        );
496        assert_eq!(height.unwrap(), uint!(400));
497        assert_eq!(width.unwrap(), uint!(800));
498        assert_eq!(mimetype.as_ref().unwrap().as_str(), "image/jpeg");
499    }
500
501    #[test]
502    #[cfg(feature = "unstable-msc1767")]
503    fn deserialize_extensible_example() {
504        use crate::message::MessageEventContent;
505        let normal_preview = json!({
506            "m.text": [
507                {"body": "matrix.org/support"}
508            ],
509            "m.url_previews": [
510                {
511                    "matrix:matched_url": "matrix.org/support",
512                    "matrix:image:size": 16588,
513                    "og:description": "Matrix, the open protocol for secure decentralised communications",
514                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
515                    "og:image:height": 400,
516                    "og:image:type": "image/jpeg",
517                    "og:image:width": 800,
518                    "og:title": "Support Matrix",
519                    "og:url": "https://matrix.org/support/"
520                }
521            ],
522            "m.mentions": {}
523        });
524
525        let message_with_preview: MessageEventContent = from_json_value(normal_preview).unwrap();
526        let MessageEventContent { url_previews, .. } = message_with_preview;
527        let previews = url_previews.expect("No url previews found");
528        assert_eq!(previews.len(), 1);
529        let preview = previews.first().unwrap();
530        assert!(preview.contains_preview());
531        let UrlPreview { image, matched_url, title, url, description } = preview;
532        assert_eq!(matched_url.as_ref().unwrap(), "matrix.org/support");
533        assert_eq!(title.as_ref().unwrap(), "Support Matrix");
534        assert_eq!(
535            description.as_ref().unwrap(),
536            "Matrix, the open protocol for secure decentralised communications"
537        );
538        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/support/");
539
540        // Check the preview image parsed:
541        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
542        assert_eq!(size.unwrap(), uint!(16588));
543        assert_matches!(source, PreviewImageSource::Url(url));
544        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
545        assert_eq!(height.unwrap(), uint!(400));
546        assert_eq!(width.unwrap(), uint!(800));
547        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
548    }
549}