1use serde::{Deserialize, Serialize};
2
3use crate::room::{EncryptedFile, OwnedMxcUri, UInt};
4
5#[derive(Clone, Debug, Deserialize, Serialize)]
7#[allow(clippy::exhaustive_enums)]
8pub enum PreviewImageSource {
9 #[serde(rename = "beeper:image:encryption", alias = "matrix:image:encryption")]
11 EncryptedImage(EncryptedFile),
12
13 #[serde(rename = "og:image", alias = "og:image:url")]
15 Url(OwnedMxcUri),
16}
17
18#[derive(Clone, Debug, Deserialize, Serialize)]
22#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
23pub struct PreviewImage {
24 #[serde(flatten)]
26 pub source: PreviewImageSource,
27
28 #[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 #[serde(rename = "og:image:width", skip_serializing_if = "Option::is_none")]
38 pub width: Option<UInt>,
39
40 #[serde(rename = "og:image:height", skip_serializing_if = "Option::is_none")]
42 pub height: Option<UInt>,
43
44 #[serde(rename = "og:image:type", skip_serializing_if = "Option::is_none")]
46 pub mimetype: Option<String>,
47}
48
49impl PreviewImage {
50 pub fn plain(url: OwnedMxcUri) -> Self {
52 Self::with_image(PreviewImageSource::Url(url))
53 }
54
55 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#[derive(Clone, Debug, Deserialize, Serialize)]
68#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
69pub struct UrlPreview {
70 #[serde(alias = "matrix:matched_url")]
72 pub matched_url: Option<String>,
73
74 #[serde(rename = "og:url", skip_serializing_if = "Option::is_none")]
76 pub url: Option<String>,
77
78 #[serde(rename = "og:title", skip_serializing_if = "Option::is_none")]
80 pub title: Option<String>,
81
82 #[serde(rename = "og:description", skip_serializing_if = "Option::is_none")]
84 pub description: Option<String>,
85
86 #[serde(flatten, skip_serializing_if = "Option::is_none")]
88 pub image: Option<PreviewImage>,
89}
90
91impl UrlPreview {
92 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 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 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 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 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 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 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}