diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 6437ed08..45624238 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -1,5 +1,10 @@ # [unreleased] +Breaking changes: + +* Change `events::room` media types to accept either a plain file or an + encrypted file, not both simultaneously + # 0.8.0 Breaking changes: diff --git a/crates/ruma-common/src/events/room.rs b/crates/ruma-common/src/events/room.rs index 2ead7623..cff4ddc6 100644 --- a/crates/ruma-common/src/events/room.rs +++ b/crates/ruma-common/src/events/room.rs @@ -29,9 +29,23 @@ pub mod power_levels; pub mod redaction; pub mod server_acl; pub mod third_party_invite; +mod thumbnail_src_serde; pub mod tombstone; pub mod topic; +/// The source of a media file. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub enum MediaSource { + /// The MXC URI to the unencrypted media file. + #[serde(rename = "url")] + Plain(Box), + + /// The encryption info of the encrypted media file. + #[serde(rename = "file")] + Encrypted(Box), +} + /// Metadata about an image. #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] @@ -52,21 +66,13 @@ pub struct ImageInfo { #[serde(skip_serializing_if = "Option::is_none")] pub size: Option, - /// Metadata about the image referred to in `thumbnail_url`. + /// Metadata about the image referred to in `thumbnail_src`. #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_info: Option>, - /// The URL to the thumbnail of the image. - /// - /// Only present if the thumbnail is unencrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_url: Option>, - - /// Information on the encrypted thumbnail image. - /// - /// Only present if the thumbnail is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_file: Option>, + /// The source of the thumbnail of the image. + #[serde(flatten, with = "thumbnail_src_serde", skip_serializing_if = "Option::is_none")] + pub thumbnail_src: Option, /// The [BlurHash](https://blurha.sh) for this image. /// diff --git a/crates/ruma-common/src/events/room/message.rs b/crates/ruma-common/src/events/room/message.rs index 5f9f7d5f..26b5be2c 100644 --- a/crates/ruma-common/src/events/room/message.rs +++ b/crates/ruma-common/src/events/room/message.rs @@ -9,7 +9,7 @@ use ruma_macros::EventContent; use serde::{de::DeserializeOwned, Deserialize, Serialize}; use serde_json::Value as JsonValue; -use super::{EncryptedFile, ImageInfo, ThumbnailInfo}; +use super::{EncryptedFile, ImageInfo, MediaSource, ThumbnailInfo}; #[cfg(feature = "unstable-msc1767")] use crate::events::{ emote::EmoteEventContent, @@ -474,17 +474,9 @@ pub struct AudioMessageEventContent { /// The textual representation of this message. pub body: String, - /// The URL to the audio clip. - /// - /// Required if the file is unencrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option>, - - /// Information on the encrypted audio clip. - /// - /// Required if the audio clip is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option>, + /// The source of the audio clip. + #[serde(flatten)] + pub src: MediaSource, /// Metadata for the audio clip referred to in `url`. #[serde(skip_serializing_if = "Option::is_none")] @@ -495,13 +487,13 @@ impl AudioMessageEventContent { /// Creates a new non-encrypted `RoomAudioMessageEventContent` with the given body, url and /// optional extra info. pub fn plain(body: String, url: Box, info: Option>) -> Self { - Self { body, url: Some(url), info, file: None } + Self { body, src: MediaSource::Plain(url), info } } /// Creates a new encrypted `RoomAudioMessageEventContent` with the given body and encrypted /// file. pub fn encrypted(body: String, file: EncryptedFile) -> Self { - Self { body, url: None, info: None, file: Some(Box::new(file)) } + Self { body, src: MediaSource::Encrypted(Box::new(file)), info: None } } } @@ -616,15 +608,9 @@ pub struct FileMessageEventContent { #[serde(skip_serializing_if = "Option::is_none")] pub filename: Option, - /// The URL to the file. - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option>, - - /// Information on the encrypted file. - /// - /// Required if file is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option>, + /// The source of the file. + #[serde(flatten)] + pub src: MediaSource, /// Metadata about the file referred to in `url`. #[serde(skip_serializing_if = "Option::is_none")] @@ -635,13 +621,13 @@ 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, url: Some(url), info, file: None } + Self { body, filename: None, src: 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, url: None, info: None, file: Some(Box::new(file)) } + Self { body, filename: None, src: MediaSource::Encrypted(Box::new(file)), info: None } } } @@ -657,21 +643,13 @@ pub struct FileInfo { #[serde(skip_serializing_if = "Option::is_none")] pub size: Option, - /// Metadata about the image referred to in `thumbnail_url`. + /// Metadata about the image referred to in `thumbnail_src`. #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_info: Option>, - /// The URL to the thumbnail of the file. - /// - /// Only present if the thumbnail is unencrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_url: Option>, - - /// Information on the encrypted thumbnail file. - /// - /// Only present if the thumbnail is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_file: Option>, + /// The source of the thumbnail of the file. + #[serde(flatten, with = "super::thumbnail_src_serde", skip_serializing_if = "Option::is_none")] + pub thumbnail_src: Option, } impl FileInfo { @@ -692,15 +670,9 @@ pub struct ImageMessageEventContent { /// description for accessibility e.g. "image attachment". pub body: String, - /// The URL to the image. - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option>, - - /// Information on the encrypted image. - /// - /// Required if image is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option>, + /// The source of the image. + #[serde(flatten)] + pub src: MediaSource, /// Metadata about the image referred to in `url`. #[serde(skip_serializing_if = "Option::is_none")] @@ -711,13 +683,13 @@ impl ImageMessageEventContent { /// Creates a new non-encrypted `RoomImageMessageEventContent` with the given body, url and /// optional extra info. pub fn plain(body: String, url: Box, info: Option>) -> Self { - Self { body, url: Some(url), info, file: None } + Self { body, src: MediaSource::Plain(url), info } } /// Creates a new encrypted `RoomImageMessageEventContent` with the given body and encrypted /// file. pub fn encrypted(body: String, file: EncryptedFile) -> Self { - Self { body, url: None, info: None, file: Some(Box::new(file)) } + Self { body, src: MediaSource::Encrypted(Box::new(file)), info: None } } } @@ -749,19 +721,11 @@ impl LocationMessageEventContent { #[derive(Clone, Debug, Default, Deserialize, Serialize)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] pub struct LocationInfo { - /// The URL to a thumbnail of the location being represented. - /// - /// Only present if the thumbnail is unencrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_url: Option>, + /// The URL to a thumbnail of the location. + #[serde(flatten, with = "super::thumbnail_src_serde", skip_serializing_if = "Option::is_none")] + pub thumbnail_src: Option, - /// Information on an encrypted thumbnail of the location being represented. - /// - /// Only present if the thumbnail is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_file: Option>, - - /// Metadata about the image referred to in `thumbnail_url` or `thumbnail_file`. + /// Metadata about the image referred to in `thumbnail_src. #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_info: Option>, } @@ -1053,15 +1017,9 @@ pub struct VideoMessageEventContent { /// accessibility, e.g. "video attachment". pub body: String, - /// The URL to the video clip. - #[serde(skip_serializing_if = "Option::is_none")] - pub url: Option>, - - /// Information on the encrypted video clip. - /// - /// Required if video clip is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub file: Option>, + /// The source of the video clip. + #[serde(flatten)] + pub src: MediaSource, /// Metadata about the video clip referred to in `url`. #[serde(skip_serializing_if = "Option::is_none")] @@ -1072,13 +1030,13 @@ impl VideoMessageEventContent { /// Creates a new non-encrypted `RoomVideoMessageEventContent` with the given body, url and /// optional extra info. pub fn plain(body: String, url: Box, info: Option>) -> Self { - Self { body, url: Some(url), info, file: None } + Self { body, src: MediaSource::Plain(url), info } } /// Creates a new encrypted `RoomVideoMessageEventContent` with the given body and encrypted /// file. pub fn encrypted(body: String, file: EncryptedFile) -> Self { - Self { body, url: None, info: None, file: Some(Box::new(file)) } + Self { body, src: MediaSource::Encrypted(Box::new(file)), info: None } } } @@ -1110,21 +1068,13 @@ pub struct VideoInfo { #[serde(skip_serializing_if = "Option::is_none")] pub size: Option, - /// Metadata about an image. + /// Metadata about the image referred to in `thumbnail_src`. #[serde(skip_serializing_if = "Option::is_none")] pub thumbnail_info: Option>, - /// The URL to an image thumbnail of the video clip. - /// - /// Only present if the thumbnail is unencrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_url: Option>, - - /// Information on the encrypted thumbnail file. - /// - /// Only present if the thumbnail is encrypted. - #[serde(skip_serializing_if = "Option::is_none")] - pub thumbnail_file: Option>, + /// The source of the thumbnail of the video clip. + #[serde(flatten, with = "super::thumbnail_src_serde", skip_serializing_if = "Option::is_none")] + pub thumbnail_src: Option, /// The [BlurHash](https://blurha.sh) for this video. /// diff --git a/crates/ruma-common/src/events/room/thumbnail_src_serde.rs b/crates/ruma-common/src/events/room/thumbnail_src_serde.rs new file mode 100644 index 00000000..87781607 --- /dev/null +++ b/crates/ruma-common/src/events/room/thumbnail_src_serde.rs @@ -0,0 +1,207 @@ +//! De-/serialization functions for `Option` objects representing a thumbnail source. + +use serde::{ + de::Deserializer, + ser::{SerializeStruct, Serializer}, + Deserialize, +}; + +use crate::MxcUri; + +use super::{EncryptedFile, MediaSource}; + +/// Serializes a MediaSource to a thumbnail source. +pub fn serialize(src: &Option, serializer: S) -> Result +where + S: Serializer, +{ + if let Some(src) = src { + let mut st = serializer.serialize_struct("ThumbnailSource", 1)?; + match src { + MediaSource::Plain(url) => st.serialize_field("thumbnail_url", url)?, + MediaSource::Encrypted(file) => st.serialize_field("thumbnail_file", file)?, + } + st.end() + } else { + serializer.serialize_none() + } +} + +/// Deserializes a thumbnail source to a MediaSource. +pub fn deserialize<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + Option::::deserialize(deserializer).map(|src| src.map(Into::into)) +} + +#[derive(Clone, Debug, Deserialize)] +enum ThumbnailSource { + /// The MXC URI to the unencrypted media file. + #[serde(rename = "thumbnail_url")] + Plain(Box), + + /// The encryption info of the encrypted media file. + #[serde(rename = "thumbnail_file")] + Encrypted(Box), +} + +impl From for MediaSource { + fn from(src: ThumbnailSource) -> Self { + match src { + ThumbnailSource::Plain(url) => Self::Plain(url), + ThumbnailSource::Encrypted(file) => Self::Encrypted(file), + } + } +} + +#[cfg(test)] +mod tests { + use matches::assert_matches; + use serde::{Deserialize, Serialize}; + use serde_json::json; + + use crate::{ + events::room::{EncryptedFileInit, JsonWebKeyInit, MediaSource}, + mxc_uri, + serde::Base64, + }; + + #[derive(Clone, Debug, Deserialize, Serialize)] + struct ThumbnailSourceTest { + #[serde(flatten, with = "super", skip_serializing_if = "Option::is_none")] + src: Option, + } + + #[test] + fn deserialize_plain() { + let json = json!({ "thumbnail_url": "mxc://notareal.hs/abcdef" }); + + assert_matches!( + serde_json::from_value::(json).unwrap(), + ThumbnailSourceTest { src: Some(MediaSource::Plain(url)) } + if url == "mxc://notareal.hs/abcdef" + ); + } + + #[test] + fn deserialize_encrypted() { + let json = json!({ + "thumbnail_file": { + "url": "mxc://notareal.hs/abcdef", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + }); + + assert_matches!( + serde_json::from_value::(json).unwrap(), + ThumbnailSourceTest { src: Some(MediaSource::Encrypted(file)) } + if file.url == "mxc://notareal.hs/abcdef" + ); + } + + #[test] + fn deserialize_none_by_absence() { + let json = json!({}); + + assert_matches!( + serde_json::from_value::(json).unwrap(), + ThumbnailSourceTest { src: None } + ); + } + + #[test] + fn deserialize_none_by_null_plain() { + let json = json!({ "thumbnail_url": null }); + + assert_matches!( + serde_json::from_value::(json).unwrap(), + ThumbnailSourceTest { src: None } + ); + } + + #[test] + fn deserialize_none_by_null_encrypted() { + let json = json!({ "thumbnail_file": null }); + + assert_matches!( + serde_json::from_value::(json).unwrap(), + ThumbnailSourceTest { src: None } + ); + } + + #[test] + fn serialize_plain() { + let request = ThumbnailSourceTest { + src: Some(MediaSource::Plain(mxc_uri!("mxc://notareal.hs/abcdef").into())), + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + json!({ "thumbnail_url": "mxc://notareal.hs/abcdef" }) + ); + } + + #[test] + fn serialize_encrypted() { + let request = ThumbnailSourceTest { + src: Some(MediaSource::Encrypted(Box::new( + EncryptedFileInit { + url: mxc_uri!("mxc://notareal.hs/abcdef").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!( + serde_json::to_value(&request).unwrap(), + json!({ + "thumbnail_file": { + "url": "mxc://notareal.hs/abcdef", + "key": { + "kty": "oct", + "key_ops": ["encrypt", "decrypt"], + "alg": "A256CTR", + "k": "TLlG_OpX807zzQuuwv4QZGJ21_u7weemFGYJFszMn9A", + "ext": true + }, + "iv": "S22dq3NAX8wAAAAAAAAAAA", + "hashes": { + "sha256": "aWOHudBnDkJ9IwaR1Nd8XKoI7DOrqDTwt6xDPfVGN6Q" + }, + "v": "v2", + }, + }) + ); + } + + #[test] + fn serialize_none() { + let request = ThumbnailSourceTest { src: None }; + assert_eq!(serde_json::to_value(&request).unwrap(), json!({})); + } +} diff --git a/crates/ruma-common/tests/events/message_event.rs b/crates/ruma-common/tests/events/message_event.rs index e3f05619..9ff44a10 100644 --- a/crates/ruma-common/tests/events/message_event.rs +++ b/crates/ruma-common/tests/events/message_event.rs @@ -5,7 +5,7 @@ use ruma_common::{ event_id, events::{ call::{answer::CallAnswerEventContent, SessionDescription, SessionDescriptionType}, - room::{ImageInfo, ThumbnailInfo}, + room::{ImageInfo, MediaSource, ThumbnailInfo}, sticker::StickerEventContent, AnyMessageLikeEvent, AnyMessageLikeEventContent, AnySyncMessageLikeEvent, MessageLikeEvent, MessageLikeEventType, MessageLikeUnsigned, @@ -32,7 +32,7 @@ fn message_serialize_sticker() { mimetype: Some("image/png".into()), size: UInt::new(82595), }))), - thumbnail_url: Some(mxc_uri!("mxc://matrix.org/irsns989Rrsn").to_owned()), + thumbnail_src: Some(MediaSource::Plain(mxc_uri!("mxc://matrix.org/irsns989Rrsn").to_owned())), }), mxc_uri!("mxc://matrix.org/rnsldl8srs98IRrs").to_owned(), ), @@ -184,8 +184,7 @@ fn deserialize_message_sticker() { mimetype: Some(mimetype), size, thumbnail_info: Some(thumbnail_info), - thumbnail_url: Some(thumbnail_url), - thumbnail_file: None, + thumbnail_src: Some(MediaSource::Plain(thumbnail_url)), #[cfg(feature = "unstable-msc2448")] blurhash: None, .. diff --git a/crates/ruma-common/tests/events/room_message.rs b/crates/ruma-common/tests/events/room_message.rs index f8e4f8d9..3f595f32 100644 --- a/crates/ruma-common/tests/events/room_message.rs +++ b/crates/ruma-common/tests/events/room_message.rs @@ -12,9 +12,12 @@ use ruma_common::{ event_id, events::{ key::verification::VerificationMethod, - room::message::{ - AudioMessageEventContent, KeyVerificationRequestEventContent, MessageType, - RoomMessageEvent, RoomMessageEventContent, TextMessageEventContent, + room::{ + message::{ + AudioMessageEventContent, KeyVerificationRequestEventContent, MessageType, + RoomMessageEvent, RoomMessageEventContent, TextMessageEventContent, + }, + MediaSource, }, MessageLikeUnsigned, }, @@ -443,8 +446,7 @@ fn content_deserialization() { msgtype: MessageType::Audio(AudioMessageEventContent { body, info: None, - url: Some(url), - file: None, + src: MediaSource::Plain(url), .. }), ..