From 8673d0d3f65b2fff29c696b4b02280d2fb9ad3fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 26 Mar 2022 15:21:57 +0100 Subject: [PATCH] events: Add support for transitional extensible file messages According to MSC3551 --- crates/ruma-common/src/events/file.rs | 80 +++++- crates/ruma-common/src/events/room.rs | 23 ++ crates/ruma-common/src/events/room/message.rs | 82 +++++- .../src/events/room/message/content_serde.rs | 54 ++++ crates/ruma-common/tests/events/file.rs | 269 +++++++++++++++++- 5 files changed, 499 insertions(+), 9 deletions(-) diff --git a/crates/ruma-common/src/events/file.rs b/crates/ruma-common/src/events/file.rs index f2b5cb79..d8467413 100644 --- a/crates/ruma-common/src/events/file.rs +++ b/crates/ruma-common/src/events/file.rs @@ -10,11 +10,26 @@ use serde::{Deserialize, Serialize}; use super::{ message::MessageContent, - room::{message::Relation, JsonWebKey}, + room::{ + message::{FileInfo, FileMessageEventContent, Relation}, + EncryptedFile, JsonWebKey, MediaSource, + }, }; use crate::{serde::Base64, MxcUri}; -/// The payload for an extensible text message. +/// The payload for an extensible file message. +/// +/// This is the new primary type introduced in [MSC3551] and should not be sent before the end of +/// the transition period. See the documentation of the [`message`] module for more information. +/// +/// `FileEventContent` can be converted to a [`RoomMessageEventContent`] with a +/// [`MessageType::File`]. You can convert it back with +/// [`FileEventContent::from_file_room_message()`]. +/// +/// [MSC3551]: https://github.com/matrix-org/matrix-spec-proposals/pull/3551 +/// [`message`]: super::message +/// [`RoomMessageEventContent`]: super::room::message::RoomMessageEventContent +/// [`MessageType::File`]: super::room::message::MessageType::File #[derive(Clone, Debug, Serialize, Deserialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[ruma_event(type = "m.file", kind = MessageLike)] @@ -84,6 +99,22 @@ impl FileEventContent { ) -> Self { Self { message, file: FileContent::encrypted(url, encryption_info, info), relates_to: None } } + + /// Create a new `FileEventContent` from the given `FileMessageEventContent` and optional + /// relation. + pub fn from_file_room_message( + content: FileMessageEventContent, + relates_to: Option, + ) -> Self { + let FileMessageEventContent { body, filename, source, info, message, file } = content; + + let message = message.unwrap_or_else(|| MessageContent::plain(body)); + let file = file.unwrap_or_else(|| { + FileContent::from_room_message_content(source, info.as_deref(), filename) + }); + + Self { message, file, relates_to } + } } /// File content. @@ -119,6 +150,21 @@ impl FileContent { Self { url, info, encryption_info: Some(Box::new(encryption_info)) } } + /// Create a new `FileContent` with the given media source, file info and filename. + pub fn from_room_message_content( + source: MediaSource, + info: Option>, + filename: Option, + ) -> Self { + let (url, encryption_info) = match source { + MediaSource::Plain(url) => (url, None), + MediaSource::Encrypted(file) => (file.url.to_owned(), Some(Box::new((&*file).into()))), + }; + let info = FileContentInfo::from_room_message_content(info, filename).map(Box::new); + + Self { url, encryption_info, info } + } + /// Whether the file is encrypted. pub fn is_encrypted(&self) -> bool { self.encryption_info.is_some() @@ -147,6 +193,29 @@ impl FileContentInfo { pub fn new() -> Self { Self::default() } + + /// Create a new `FileContentInfo` with the given file info and filename. + /// + /// Returns `None` if both parameters are `None` + pub fn from_room_message_content( + info: Option>, + filename: Option, + ) -> Option { + if filename.is_none() && info.is_none() { + None + } else { + let mut info: Self = info.map(Into::into).unwrap_or_default(); + info.name = filename; + Some(info) + } + } +} + +impl From<&FileInfo> for FileContentInfo { + fn from(info: &FileInfo) -> Self { + let FileInfo { mimetype, size, .. } = info; + Self { mimetype: mimetype.to_owned(), size: size.to_owned(), ..Default::default() } + } } /// The encryption info of a file sent to a room with end-to-end encryption enabled. @@ -203,3 +272,10 @@ impl From for EncryptedContent { Self { key, iv, hashes, v } } } + +impl From<&EncryptedFile> for EncryptedContent { + fn from(encrypted: &EncryptedFile) -> Self { + let EncryptedFile { key, iv, hashes, v, .. } = encrypted.to_owned(); + Self { key, iv, hashes, v } + } +} diff --git a/crates/ruma-common/src/events/room.rs b/crates/ruma-common/src/events/room.rs index b3b72a0a..8dd6c8e2 100644 --- a/crates/ruma-common/src/events/room.rs +++ b/crates/ruma-common/src/events/room.rs @@ -7,6 +7,8 @@ use std::collections::BTreeMap; use js_int::UInt; use serde::{de, Deserialize, Serialize}; +#[cfg(feature = "unstable-msc3551")] +use super::file::{EncryptedContent, FileContent}; use crate::{ serde::{base64::UrlSafe, Base64}, MxcUri, @@ -70,6 +72,18 @@ impl<'de> Deserialize<'de> for MediaSource { } } +#[cfg(feature = "unstable-msc3551")] +impl From<&FileContent> for MediaSource { + fn from(content: &FileContent) -> Self { + let FileContent { url, encryption_info, .. } = content; + if let Some(encryption_info) = encryption_info.as_deref() { + Self::Encrypted(Box::new(EncryptedFile::from_extensible_content(url, encryption_info))) + } else { + Self::Plain(url.to_owned()) + } + } +} + /// Metadata about an image. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] @@ -173,6 +187,15 @@ pub struct EncryptedFile { pub v: String, } +#[cfg(feature = "unstable-msc3551")] +impl EncryptedFile { + /// Create an `EncryptedFile` from the given url and encryption info. + pub fn from_extensible_content(url: &MxcUri, encryption_info: &EncryptedContent) -> Self { + let EncryptedContent { key, iv, hashes, v } = encryption_info.to_owned(); + Self { url: url.to_owned(), key, iv, hashes, v } + } +} + /// Initial set of fields of `EncryptedFile`. /// /// This struct will not be updated even if additional fields are added to `EncryptedFile` in a new diff --git a/crates/ruma-common/src/events/room/message.rs b/crates/ruma-common/src/events/room/message.rs index d3b9c5b8..5773637f 100644 --- a/crates/ruma-common/src/events/room/message.rs +++ b/crates/ruma-common/src/events/room/message.rs @@ -10,6 +10,8 @@ use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value as JsonValue; use super::{EncryptedFile, ImageInfo, MediaSource, ThumbnailInfo}; +#[cfg(feature = "unstable-msc3551")] +use crate::events::file::{FileContent, FileContentInfo, FileEventContent}; #[cfg(feature = "unstable-msc1767")] use crate::events::{ emote::EmoteEventContent, @@ -197,6 +199,20 @@ impl From for RoomMessageEventContent { } } +#[cfg(feature = "unstable-msc3551")] +impl From for RoomMessageEventContent { + fn from(content: FileEventContent) -> Self { + let FileEventContent { message, file, relates_to } = content; + + Self { + msgtype: MessageType::File(FileMessageEventContent::from_extensible_content( + message, file, + )), + relates_to, + } + } +} + #[cfg(feature = "unstable-msc1767")] impl From for RoomMessageEventContent { fn from(content: MessageEventContent) -> Self { @@ -597,6 +613,10 @@ impl From for EmoteMessageEventContent { #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[serde(tag = "msgtype", rename = "m.file")] +#[cfg_attr( + feature = "unstable-msc3551", + serde(from = "content_serde::FileMessageEventContentDeHelper") +)] pub struct FileMessageEventContent { /// A human-readable description of the file. /// @@ -614,19 +634,69 @@ pub struct FileMessageEventContent { /// Metadata about the file referred to in `url`. #[serde(skip_serializing_if = "Option::is_none")] pub info: Option>, + + /// Extensible-event text representation of the message. + /// + /// If present, this should be preferred over the `body` field. + #[cfg(feature = "unstable-msc3551")] + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub message: Option, + + /// Extensible-event file content of the message. + /// + /// If present, this should be preferred over the `source` and `info` fields. + #[cfg(feature = "unstable-msc3551")] + #[serde(rename = "org.matrix.msc1767.file", skip_serializing_if = "Option::is_none")] + pub file: Option, } impl FileMessageEventContent { /// Creates a new non-encrypted `RoomFileMessageEventContent` with the given body, url and /// optional extra info. pub fn plain(body: String, url: Box, info: Option>) -> Self { - Self { body, filename: None, source: MediaSource::Plain(url), info } + Self { + #[cfg(feature = "unstable-msc3551")] + message: Some(MessageContent::plain(body.clone())), + #[cfg(feature = "unstable-msc3551")] + file: Some(FileContent::plain( + url.clone(), + info.as_deref().map(|info| Box::new(info.into())), + )), + body, + filename: None, + source: MediaSource::Plain(url), + info, + } } /// Creates a new encrypted `RoomFileMessageEventContent` with the given body and encrypted /// file. pub fn encrypted(body: String, file: EncryptedFile) -> Self { - Self { body, filename: None, source: MediaSource::Encrypted(Box::new(file)), info: None } + Self { + #[cfg(feature = "unstable-msc3551")] + message: Some(MessageContent::plain(body.clone())), + #[cfg(feature = "unstable-msc3551")] + file: Some(FileContent::encrypted(file.url.clone(), (&file).into(), None)), + body, + filename: None, + source: MediaSource::Encrypted(Box::new(file)), + info: None, + } + } + + /// Create a new `RoomFileMessageEventContent` with the given message and file info. + #[cfg(feature = "unstable-msc3551")] + pub fn from_extensible_content(message: MessageContent, file: FileContent) -> Self { + let body = if let Some(body) = message.find_plain() { + body.to_owned() + } else { + message[0].body.clone() + }; + let filename = file.info.as_deref().and_then(|info| info.name.clone()); + let info = file.info.as_deref().map(|info| Box::new(info.into())); + let source = (&file).into(); + + Self { message: Some(message), file: Some(file), body, filename, source, info } } } @@ -662,6 +732,14 @@ impl FileInfo { } } +#[cfg(feature = "unstable-msc3551")] +impl From<&FileContentInfo> for FileInfo { + fn from(info: &FileContentInfo) -> Self { + let FileContentInfo { mimetype, size, .. } = info; + Self { mimetype: mimetype.to_owned(), size: size.to_owned(), ..Default::default() } + } +} + /// The payload for an image message. #[derive(Clone, Debug, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] diff --git a/crates/ruma-common/src/events/room/message/content_serde.rs b/crates/ruma-common/src/events/room/message/content_serde.rs index f2d3b770..7fe6183a 100644 --- a/crates/ruma-common/src/events/room/message/content_serde.rs +++ b/crates/ruma-common/src/events/room/message/content_serde.rs @@ -3,6 +3,8 @@ use serde::{de, Deserialize}; use serde_json::value::RawValue as RawJsonValue; +#[cfg(feature = "unstable-msc3551")] +use super::{FileContent, FileInfo, FileMessageEventContent, MediaSource, MessageContent}; use super::{MessageType, Relation, RoomMessageEventContent}; use crate::serde::from_raw_json_value; @@ -50,3 +52,55 @@ impl<'de> Deserialize<'de> for MessageType { }) } } + +/// Helper struct for deserializing `FileMessageEventContent` with stable and unstable field names. +/// +/// It's not possible to use the `alias` attribute of serde because of +/// https://github.com/serde-rs/serde/issues/1504. +#[derive(Clone, Debug, Deserialize)] +#[cfg(feature = "unstable-msc3551")] +pub struct FileMessageEventContentDeHelper { + /// A human-readable description of the file. + pub body: String, + + /// The original filename of the uploaded file. + pub filename: Option, + + /// The source of the file. + #[serde(flatten)] + pub source: MediaSource, + + /// Metadata about the file referred to in `url`. + pub info: Option>, + + /// Extensible-event text representation of the message. + #[serde(flatten)] + pub message: Option, + + /// Extensible-event file content of the message, with stable name. + #[serde(rename = "m.file")] + pub file_stable: Option, + + /// Extensible-event file content of the message, with unstable name. + #[serde(rename = "org.matrix.msc1767.file")] + pub file_unstable: Option, +} + +#[cfg(feature = "unstable-msc3551")] +impl From for FileMessageEventContent { + fn from(helper: FileMessageEventContentDeHelper) -> Self { + let FileMessageEventContentDeHelper { + body, + filename, + source, + info, + message, + file_stable, + file_unstable, + } = helper; + + let file = file_stable.or(file_unstable); + + Self { body, filename, source, info, message, file } + } +} diff --git a/crates/ruma-common/tests/events/file.rs b/crates/ruma-common/tests/events/file.rs index 0ff4885e..562c1d74 100644 --- a/crates/ruma-common/tests/events/file.rs +++ b/crates/ruma-common/tests/events/file.rs @@ -9,8 +9,10 @@ use ruma_common::{ file::{EncryptedContentInit, FileContent, FileContentInfo, FileEventContent}, message::MessageContent, room::{ - message::{InReplyTo, Relation}, - JsonWebKeyInit, + message::{ + FileMessageEventContent, InReplyTo, MessageType, Relation, RoomMessageEventContent, + }, + EncryptedFileInit, JsonWebKeyInit, MediaSource, }, AnyMessageLikeEvent, MessageLikeEvent, MessageLikeUnsigned, }, @@ -152,7 +154,7 @@ fn file_event_serialization() { #[test] fn plain_content_deserialization() { let json_data = json!({ - "org.matrix.msc1767.text": "Upload: my_file.txt", + "m.text": "Upload: my_file.txt", "m.file": { "url": "mxc://notareal.hs/abcdef", } @@ -171,7 +173,7 @@ fn plain_content_deserialization() { #[test] fn encrypted_content_deserialization() { let json_data = json!({ - "org.matrix.msc1767.text": "Upload: my_file.txt", + "m.text": "Upload: my_file.txt", "m.file": { "url": "mxc://notareal.hs/abcdef", "key": { @@ -204,7 +206,7 @@ fn encrypted_content_deserialization() { fn message_event_deserialization() { let json_data = json!({ "content": { - "org.matrix.msc1767.message": [ + "m.message": [ { "body": "Upload: my_file.txt", "mimetype": "text/html"}, { "body": "Upload: my_file.txt", "mimetype": "text/plain"}, ], @@ -252,3 +254,260 @@ fn message_event_deserialization() { && unsigned.is_empty() ); } + +#[test] +fn room_message_plain_content_serialization() { + let message_event_content = + RoomMessageEventContent::new(MessageType::File(FileMessageEventContent::plain( + "Upload: my_file.txt".to_owned(), + mxc_uri!("mxc://notareal.hs/file").to_owned(), + None, + ))); + + assert_eq!( + to_json_value(&message_event_content).unwrap(), + json!({ + "body": "Upload: my_file.txt", + "url": "mxc://notareal.hs/file", + "msgtype": "m.file", + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/file", + }, + }) + ); +} + +#[test] +fn room_message_encrypted_content_serialization() { + let message_event_content = + RoomMessageEventContent::new(MessageType::File(FileMessageEventContent::encrypted( + "Upload: my_file.txt".to_owned(), + EncryptedFileInit { + url: mxc_uri!("mxc://notareal.hs/file").to_owned(), + key: JsonWebKeyInit { + kty: "oct".to_owned(), + key_ops: vec!["encrypt".to_owned(), "decrypt".to_owned()], + alg: "A256CTR".to_owned(), + k: Base64::parse("TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A").unwrap(), + ext: true, + } + .into(), + iv: Base64::parse("S22dq3NAX8wAAAAAAAAAAA").unwrap(), + hashes: [( + "sha256".to_owned(), + Base64::parse("aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q").unwrap(), + )] + .into(), + v: "v2".to_owned(), + } + .into(), + ))); + + assert_eq!( + to_json_value(&message_event_content).unwrap(), + json!({ + "body": "Upload: my_file.txt", + "file": { + "url": "mxc://notareal.hs/file", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + "msgtype": "m.file", + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/file", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + }) + ); +} + +#[test] +fn room_message_plain_content_stable_deserialization() { + let json_data = json!({ + "body": "Upload: my_file.txt", + "url": "mxc://notareal.hs/file", + "msgtype": "m.file", + "m.text": "Upload: my_file.txt", + "m.file": { + "url": "mxc://notareal.hs/file", + }, + }); + + let event_content = from_json_value::(json_data).unwrap(); + assert_matches!(event_content.msgtype, MessageType::File(_)); + if let MessageType::File(content) = event_content.msgtype { + assert_eq!(content.body, "Upload: my_file.txt"); + assert_matches!(content.source, MediaSource::Plain(_)); + if let MediaSource::Plain(url) = content.source { + assert_eq!(url, "mxc://notareal.hs/file"); + } + let message = content.message.unwrap(); + assert_eq!(message.len(), 1); + assert_eq!(message[0].body, "Upload: my_file.txt"); + let file = content.file.unwrap(); + assert_eq!(file.url, "mxc://notareal.hs/file"); + assert!(!file.is_encrypted()); + } +} + +#[test] +fn room_message_plain_content_unstable_deserialization() { + let json_data = json!({ + "body": "Upload: my_file.txt", + "url": "mxc://notareal.hs/file", + "msgtype": "m.file", + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/file", + }, + }); + + let event_content = from_json_value::(json_data).unwrap(); + assert_matches!(event_content.msgtype, MessageType::File(_)); + if let MessageType::File(content) = event_content.msgtype { + assert_eq!(content.body, "Upload: my_file.txt"); + assert_matches!(content.source, MediaSource::Plain(_)); + if let MediaSource::Plain(url) = content.source { + assert_eq!(url, "mxc://notareal.hs/file"); + } + let message = content.message.unwrap(); + assert_eq!(message.len(), 1); + assert_eq!(message[0].body, "Upload: my_file.txt"); + let file = content.file.unwrap(); + assert_eq!(file.url, "mxc://notareal.hs/file"); + assert!(!file.is_encrypted()); + } +} + +#[test] +fn room_message_encrypted_content_stable_deserialization() { + let json_data = json!({ + "body": "Upload: my_file.txt", + "file": { + "url": "mxc://notareal.hs/file", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + "msgtype": "m.file", + "m.text": "Upload: my_file.txt", + "m.file": { + "url": "mxc://notareal.hs/file", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + }); + + let event_content = from_json_value::(json_data).unwrap(); + assert_matches!(event_content.msgtype, MessageType::File(_)); + if let MessageType::File(content) = event_content.msgtype { + assert_eq!(content.body, "Upload: my_file.txt"); + assert_matches!(content.source, MediaSource::Encrypted(_)); + if let MediaSource::Encrypted(encrypted_file) = content.source { + assert_eq!(encrypted_file.url, "mxc://notareal.hs/file"); + } + let message = content.message.unwrap(); + assert_eq!(message.len(), 1); + assert_eq!(message[0].body, "Upload: my_file.txt"); + let file = content.file.unwrap(); + assert_eq!(file.url, "mxc://notareal.hs/file"); + assert!(file.is_encrypted()); + } +} + +#[test] +fn room_message_encrypted_content_unstable_deserialization() { + let json_data = json!({ + "body": "Upload: my_file.txt", + "file": { + "url": "mxc://notareal.hs/file", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + "msgtype": "m.file", + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/file", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + }); + + let event_content = from_json_value::(json_data).unwrap(); + assert_matches!(event_content.msgtype, MessageType::File(_)); + if let MessageType::File(content) = event_content.msgtype { + assert_eq!(content.body, "Upload: my_file.txt"); + assert_matches!(content.source, MediaSource::Encrypted(_)); + if let MediaSource::Encrypted(encrypted_file) = content.source { + assert_eq!(encrypted_file.url, "mxc://notareal.hs/file"); + } + let message = content.message.unwrap(); + assert_eq!(message.len(), 1); + assert_eq!(message[0].body, "Upload: my_file.txt"); + let file = content.file.unwrap(); + assert_eq!(file.url, "mxc://notareal.hs/file"); + assert!(file.is_encrypted()); + } +}