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::{owned_mxc_uri, serde::Base64};
132    use ruma_events::room::message::{MessageType, RoomMessageEventContent};
133    use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
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 expected_result = json!({
163            "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO"
164        });
165
166        let preview =
167            PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO"));
168
169        assert_eq!(to_json_value(&preview).unwrap(), expected_result);
170
171        let encrypted_result = json!({
172            "beeper:image:encryption": {
173                "hashes" : {
174                    "sha256": "AQEBAQEBAQEBAQ",
175                },
176                "iv": "AQEBAQEBAQEBAQEB",
177                "key": {
178                    "alg": "A256CTR",
179                    "ext": true,
180                    "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
181                    "key_ops": [
182                        "encrypt",
183                        "decrypt"
184                    ],
185                    "kty": "oct",
186                },
187                "v": "v2",
188                "url": "mxc://localhost/encryptedfile",
189            },
190        });
191
192        let preview = PreviewImage::encrypted(encrypted_file());
193
194        assert_eq!(to_json_value(&preview).unwrap(), encrypted_result);
195    }
196
197    #[test]
198    fn serialize_room_message_with_url_preview() {
199        let expected_result = json!({
200            "msgtype": "m.text",
201            "body": "Test message",
202            "com.beeper.linkpreviews": [
203                {
204                    "matched_url": "https://matrix.org/",
205                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
206                }
207            ]
208        });
209
210        let preview_img =
211            PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO"));
212        let full_preview = assign!(UrlPreview::matched_url("https://matrix.org/".to_owned()), {image: Some(preview_img)});
213        let msg = MessageType::Text(assign!(TextMessageEventContent::plain("Test message"),  {
214            url_previews: Some(vec![full_preview])
215        }));
216
217        assert_eq!(to_json_value(RoomMessageEventContent::new(msg)).unwrap(), expected_result);
218    }
219
220    #[test]
221    fn serialize_room_message_with_url_preview_with_encrypted_image() {
222        let expected_result = json!({
223            "msgtype": "m.text",
224            "body": "Test message",
225            "com.beeper.linkpreviews": [
226                {
227                    "matched_url": "https://matrix.org/",
228                    "beeper:image:encryption": {
229                        "hashes" : {
230                            "sha256": "AQEBAQEBAQEBAQ",
231                        },
232                        "iv": "AQEBAQEBAQEBAQEB",
233                        "key": {
234                            "alg": "A256CTR",
235                            "ext": true,
236                            "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
237                            "key_ops": [
238                                "encrypt",
239                                "decrypt"
240                            ],
241                            "kty": "oct",
242                        },
243                        "v": "v2",
244                        "url": "mxc://localhost/encryptedfile",
245                    }
246                }
247            ]
248        });
249
250        let preview_img = PreviewImage::encrypted(encrypted_file());
251        let full_preview = assign!(UrlPreview::matched_url("https://matrix.org/".to_owned()), {
252            image: Some(preview_img),
253        });
254
255        let msg = MessageType::Text(assign!(TextMessageEventContent::plain("Test message"),  {
256            url_previews: Some(vec![full_preview])
257        }));
258
259        assert_eq!(to_json_value(RoomMessageEventContent::new(msg)).unwrap(), expected_result);
260    }
261
262    #[cfg(feature = "unstable-msc1767")]
263    #[test]
264    fn serialize_extensible_room_message_with_preview() {
265        use crate::message::MessageEventContent;
266        let expected_result = json!({
267            "org.matrix.msc1767.text": [
268                {"body": "matrix.org/support"}
269            ],
270            "com.beeper.linkpreviews": [
271                {
272                    "matched_url": "matrix.org/support",
273                    "matrix:image:size": 16588,
274                    "og:description": "Matrix, the open protocol for secure decentralised communications",
275                    "og:image":"mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
276                    "og:image:height": 400,
277                    "og:image:type": "image/jpeg",
278                    "og:image:width": 800,
279                    "og:title": "Support Matrix",
280                    "og:url": "https://matrix.org/support/"
281                }
282            ],
283        });
284
285        let preview_img = assign!(PreviewImage::plain(owned_mxc_uri!("mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO")), {
286                height: Some(uint!(400)),
287                width: Some(uint!(800)),
288                mimetype: Some("image/jpeg".to_owned()),
289                size: Some(uint!(16588))
290        });
291        let full_preview = assign!(UrlPreview::matched_url("matrix.org/support".to_owned()), {
292                   image: Some(preview_img),
293                   url: Some("https://matrix.org/support/".to_owned()),
294                   title: Some("Support Matrix".to_owned()),
295                   description: Some("Matrix, the open protocol for secure decentralised communications".to_owned()),
296        });
297        let msg = assign!(MessageEventContent::plain("matrix.org/support"),  {
298            url_previews: Some(vec![full_preview])
299        });
300        assert_eq!(to_json_value(&msg).unwrap(), expected_result);
301    }
302
303    #[test]
304    fn deserialize_regular_example() {
305        let normal_preview = json!({
306            "msgtype": "m.text",
307            "body": "https://matrix.org",
308            "m.url_previews": [
309                {
310                    "matrix:matched_url": "https://matrix.org",
311                    "matrix:image:size": 16588,
312                    "og:description": "Matrix, the open protocol for secure decentralised communications",
313                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
314                    "og:image:height": 400,
315                    "og:image:type": "image/jpeg",
316                    "og:image:width": 800,
317                    "og:title": "Matrix.org",
318                    "og:url": "https://matrix.org/"
319                }
320            ],
321            "m.mentions": {}
322        });
323
324        let message_with_preview: TextMessageEventContent =
325            from_json_value(normal_preview).unwrap();
326        let TextMessageEventContent { url_previews, .. } = message_with_preview;
327        let previews = url_previews.expect("No url previews found");
328        assert_eq!(previews.len(), 1);
329        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
330        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
331        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
332        assert_eq!(
333            description.as_ref().unwrap(),
334            "Matrix, the open protocol for secure decentralised communications"
335        );
336        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
337
338        // Check the preview image parsed:
339        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
340        assert_eq!(size.unwrap(), uint!(16588));
341        assert_matches!(source, PreviewImageSource::Url(url));
342        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
343        assert_eq!(height.unwrap(), uint!(400));
344        assert_eq!(width.unwrap(), uint!(800));
345        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
346    }
347
348    #[test]
349    fn deserialize_under_dev_prefix() {
350        let normal_preview = json!({
351            "msgtype": "m.text",
352            "body": "https://matrix.org",
353            "com.beeper.linkpreviews": [
354                {
355                    "matched_url": "https://matrix.org",
356                    "matrix:image:size": 16588,
357                    "og:description": "Matrix, the open protocol for secure decentralised communications",
358                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
359                    "og:image:height": 400,
360                    "og:image:type": "image/jpeg",
361                    "og:image:width": 800,
362                    "og:title": "Matrix.org",
363                    "og:url": "https://matrix.org/"
364                }
365            ],
366            "m.mentions": {}
367        });
368
369        let message_with_preview: TextMessageEventContent =
370            from_json_value(normal_preview).unwrap();
371        let TextMessageEventContent { url_previews, .. } = message_with_preview;
372        let previews = url_previews.expect("No url previews found");
373        assert_eq!(previews.len(), 1);
374        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
375        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
376        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
377        assert_eq!(
378            description.as_ref().unwrap(),
379            "Matrix, the open protocol for secure decentralised communications"
380        );
381        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
382
383        // Check the preview image parsed:
384        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
385        assert_eq!(size.unwrap(), uint!(16588));
386        assert_matches!(source, PreviewImageSource::Url(url));
387        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
388        assert_eq!(height.unwrap(), uint!(400));
389        assert_eq!(width.unwrap(), uint!(800));
390        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
391    }
392
393    #[test]
394    fn deserialize_example_no_previews() {
395        let normal_preview = json!({
396            "msgtype": "m.text",
397            "body": "https://matrix.org",
398            "m.url_previews": [],
399            "m.mentions": {}
400        });
401        let message_with_preview: TextMessageEventContent =
402            from_json_value(normal_preview).unwrap();
403        let TextMessageEventContent { url_previews, .. } = message_with_preview;
404        assert!(url_previews.clone().unwrap().is_empty(), "Unexpectedly found url previews");
405    }
406
407    #[test]
408    fn deserialize_example_empty_previews() {
409        let normal_preview = json!({
410            "msgtype": "m.text",
411            "body": "https://matrix.org",
412            "m.url_previews": [
413                { "matrix:matched_url": "https://matrix.org" }
414            ],
415            "m.mentions": {}
416        });
417
418        let message_with_preview: TextMessageEventContent =
419            from_json_value(normal_preview).unwrap();
420        let TextMessageEventContent { url_previews, .. } = message_with_preview;
421        let previews = url_previews.expect("No url previews found");
422        assert_eq!(previews.len(), 1);
423        let preview = previews.first().unwrap();
424        assert_eq!(preview.matched_url.as_ref().unwrap(), "https://matrix.org");
425        assert!(!preview.contains_preview());
426    }
427
428    #[test]
429    fn deserialize_encrypted_image_dev_example() {
430        let normal_preview = json!({
431            "msgtype": "m.text",
432            "body": "https://matrix.org",
433            "com.beeper.linkpreviews": [
434                {
435                    "matched_url": "https://matrix.org",
436                    "og:title": "Matrix.org",
437                    "og:url": "https://matrix.org/",
438                    "og:description": "Matrix, the open protocol for secure decentralised communications",
439                    "matrix:image:size": 16588,
440                    "og:image:height": 400,
441                    "og:image:type": "image/jpeg",
442                    "og:image:width": 800,
443                    "beeper:image:encryption": {
444                        "key": {
445                            "k": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
446                            "alg": "A256CTR",
447                            "ext": true,
448                            "kty": "oct",
449                            "key_ops": [
450                                "encrypt",
451                                "decrypt"
452                            ]
453                        },
454                        "iv": "AQEBAQEBAQEBAQEB",
455                        "hashes": {
456                            "sha256": "AQEBAQEBAQEBAQ"
457                        },
458                        "v": "v2",
459                        "url": "mxc://beeper.com/53207ac52ce3e2c722bb638987064bfdc0cc257b"
460                    }
461                }
462            ],
463            "m.mentions": {}
464        });
465
466        let message_with_preview: TextMessageEventContent =
467            from_json_value(normal_preview).unwrap();
468        let TextMessageEventContent { url_previews, .. } = message_with_preview;
469        let previews = url_previews.expect("No url previews found");
470        assert_eq!(previews.len(), 1);
471        let UrlPreview { image, matched_url, title, url, description } = previews.first().unwrap();
472        assert_eq!(matched_url.as_ref().unwrap(), "https://matrix.org");
473        assert_eq!(title.as_ref().unwrap(), "Matrix.org");
474        assert_eq!(
475            description.as_ref().unwrap(),
476            "Matrix, the open protocol for secure decentralised communications"
477        );
478        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/");
479
480        // Check the preview image parsed:
481        let PreviewImage { size, height, width, mimetype, source } = image.as_ref().unwrap();
482
483        assert_eq!(size.unwrap(), uint!(16588));
484
485        assert_matches!(source, PreviewImageSource::EncryptedImage(encrypted_image));
486        assert_eq!(
487            encrypted_image.url.as_str(),
488            "mxc://beeper.com/53207ac52ce3e2c722bb638987064bfdc0cc257b"
489        );
490        assert_eq!(height.unwrap(), uint!(400));
491        assert_eq!(width.unwrap(), uint!(800));
492        assert_eq!(mimetype.as_ref().unwrap().as_str(), "image/jpeg");
493    }
494
495    #[test]
496    #[cfg(feature = "unstable-msc1767")]
497    fn deserialize_extensible_example() {
498        use crate::message::MessageEventContent;
499        let normal_preview = json!({
500            "m.text": [
501                {"body": "matrix.org/support"}
502            ],
503            "m.url_previews": [
504                {
505                    "matrix:matched_url": "matrix.org/support",
506                    "matrix:image:size": 16588,
507                    "og:description": "Matrix, the open protocol for secure decentralised communications",
508                    "og:image": "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO",
509                    "og:image:height": 400,
510                    "og:image:type": "image/jpeg",
511                    "og:image:width": 800,
512                    "og:title": "Support Matrix",
513                    "og:url": "https://matrix.org/support/"
514                }
515            ],
516            "m.mentions": {}
517        });
518
519        let message_with_preview: MessageEventContent = from_json_value(normal_preview).unwrap();
520        let MessageEventContent { url_previews, .. } = message_with_preview;
521        let previews = url_previews.expect("No url previews found");
522        assert_eq!(previews.len(), 1);
523        let preview = previews.first().unwrap();
524        assert!(preview.contains_preview());
525        let UrlPreview { image, matched_url, title, url, description } = preview;
526        assert_eq!(matched_url.as_ref().unwrap(), "matrix.org/support");
527        assert_eq!(title.as_ref().unwrap(), "Support Matrix");
528        assert_eq!(
529            description.as_ref().unwrap(),
530            "Matrix, the open protocol for secure decentralised communications"
531        );
532        assert_eq!(url.as_ref().unwrap(), "https://matrix.org/support/");
533
534        // Check the preview image parsed:
535        let PreviewImage { size, height, width, mimetype, source } = image.clone().unwrap();
536        assert_eq!(size.unwrap(), uint!(16588));
537        assert_matches!(source, PreviewImageSource::Url(url));
538        assert_eq!(url.as_str(), "mxc://maunium.net/zeHhTqqUtUSUTUDxQisPdwZO");
539        assert_eq!(height.unwrap(), uint!(400));
540        assert_eq!(width.unwrap(), uint!(800));
541        assert_eq!(mimetype, Some("image/jpeg".to_owned()));
542    }
543}