From c37a514a8996d56cb2fa060eb45a2865516da093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Wed, 2 Mar 2022 14:53:31 +0100 Subject: [PATCH] events: Add support for extensible file events As defined in MSC3551 --- crates/ruma-events/CHANGELOG.md | 4 +- crates/ruma-events/Cargo.toml | 1 + crates/ruma-events/src/enums.rs | 4 + crates/ruma-events/src/file.rs | 206 +++++++++++++++++++++++++ crates/ruma-events/src/lib.rs | 2 + crates/ruma-events/tests/file.rs | 251 +++++++++++++++++++++++++++++++ crates/ruma/Cargo.toml | 2 + 7 files changed, 469 insertions(+), 1 deletion(-) create mode 100644 crates/ruma-events/src/file.rs create mode 100644 crates/ruma-events/tests/file.rs diff --git a/crates/ruma-events/CHANGELOG.md b/crates/ruma-events/CHANGELOG.md index 257d0c27..6551a26a 100644 --- a/crates/ruma-events/CHANGELOG.md +++ b/crates/ruma-events/CHANGELOG.md @@ -6,7 +6,9 @@ Breaking changes: Improvements: -* Add unstable support for extensible text, notice and emote message events ([MSC1767](https://github.com/matrix-org/matrix-spec-proposals/pull/1767)) +* Add unstable support for extensible message events: + * text, notice and emote ([MSC1767](https://github.com/matrix-org/matrix-spec-proposals/pull/1767)) + * file ([MSC3551](https://github.com/matrix-org/matrix-spec-proposals/pull/3551)) # 0.26.0 diff --git a/crates/ruma-events/Cargo.toml b/crates/ruma-events/Cargo.toml index 5f52b522..3f2a1ad1 100644 --- a/crates/ruma-events/Cargo.toml +++ b/crates/ruma-events/Cargo.toml @@ -26,6 +26,7 @@ unstable-msc2448 = [] unstable-msc2675 = [] unstable-msc2676 = [] unstable-msc2677 = [] +unstable-msc3551 = ["unstable-msc1767"] [dependencies] criterion = { version = "0.3.3", optional = true } diff --git a/crates/ruma-events/src/enums.rs b/crates/ruma-events/src/enums.rs index b202bebd..81a935d8 100644 --- a/crates/ruma-events/src/enums.rs +++ b/crates/ruma-events/src/enums.rs @@ -39,6 +39,8 @@ event_enum! { "m.call.candidates", #[cfg(feature = "unstable-msc1767")] "m.emote", + #[cfg(feature = "unstable-msc3551")] + "m.file", "m.key.verification.ready", "m.key.verification.start", "m.key.verification.cancel", @@ -364,6 +366,8 @@ impl AnyMessageLikeEventContent { Self::Notice(ev) => ev.relates_to.clone().map(Into::into), #[cfg(feature = "unstable-msc1767")] Self::Emote(ev) => ev.relates_to.clone().map(Into::into), + #[cfg(feature = "unstable-msc3551")] + Self::File(ev) => ev.relates_to.clone().map(Into::into), Self::CallAnswer(_) | Self::CallInvite(_) | Self::CallHangup(_) diff --git a/crates/ruma-events/src/file.rs b/crates/ruma-events/src/file.rs new file mode 100644 index 00000000..d1d3fa52 --- /dev/null +++ b/crates/ruma-events/src/file.rs @@ -0,0 +1,206 @@ +//! Types for extensible file message events ([MSC3551]). +//! +//! [MSC3551]: https://github.com/matrix-org/matrix-spec-proposals/pull/3551 + +use std::collections::BTreeMap; + +use js_int::UInt; +use ruma_identifiers::MxcUri; +use ruma_macros::EventContent; +use ruma_serde::Base64; +use serde::{Deserialize, Serialize}; + +use crate::{ + message::MessageContent, + room::{message::Relation, JsonWebKey}, +}; + +/// The encryption info of a file sent to a room with end-to-end encryption enabled. +/// +/// To create an instance of this type, first create a `EncryptedContentInit` and convert it via +/// `EncryptedContent::from` / `.into()`. +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct EncryptedContent { + /// A [JSON Web Key](https://tools.ietf.org/html/rfc7517#appendix-A.3) object. + pub key: JsonWebKey, + + /// The 128-bit unique counter block used by AES-CTR, encoded as unpadded base64. + pub iv: Base64, + + /// A map from an algorithm name to a hash of the ciphertext, encoded as unpadded base64. + /// + /// Clients should support the SHA-256 hash, which uses the key sha256. + pub hashes: BTreeMap, + + /// Version of the encrypted attachments protocol. + /// + /// Must be `v2`. + pub v: String, +} + +/// Initial set of fields of `EncryptedContent`. +/// +/// This struct will not be updated even if additional fields are added to `EncryptedContent` in a +/// new (non-breaking) release of the Matrix specification. +#[derive(Debug)] +#[allow(clippy::exhaustive_structs)] +pub struct EncryptedContentInit { + /// A [JSON Web Key](https://tools.ietf.org/html/rfc7517#appendix-A.3) object. + pub key: JsonWebKey, + + /// The 128-bit unique counter block used by AES-CTR, encoded as unpadded base64. + pub iv: Base64, + + /// A map from an algorithm name to a hash of the ciphertext, encoded as unpadded base64. + /// + /// Clients should support the SHA-256 hash, which uses the key sha256. + pub hashes: BTreeMap, + + /// Version of the encrypted attachments protocol. + /// + /// Must be `v2`. + pub v: String, +} + +impl From for EncryptedContent { + fn from(init: EncryptedContentInit) -> Self { + let EncryptedContentInit { key, iv, hashes, v } = init; + Self { key, iv, hashes, v } + } +} + +/// Information about a file content. +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct FileContentInfo { + /// The original filename of the uploaded file. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + + /// The mimetype of the file, e.g. “application/msword”. + #[serde(skip_serializing_if = "Option::is_none")] + pub mimetype: Option, + + /// The size of the file in bytes. + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +impl FileContentInfo { + /// Creates an empty `FileContentInfo`. + pub fn new() -> Self { + Self::default() + } +} + +/// File content. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct FileContent { + /// The URL to the file. + pub url: Box, + + /// Information about the uploaded file. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub info: Option>, + + /// Information on the encrypted file. + /// + /// Required if file is encrypted. + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub encryption_info: Option>, +} + +impl FileContent { + /// Creates a new non-encrypted `FileContent` with the given url and file info. + pub fn plain(url: Box, info: Option>) -> Self { + Self { url, info, encryption_info: None } + } + + /// Creates a new encrypted `FileContent` with the given url, encryption info and file info. + pub fn encrypted( + url: Box, + encryption_info: EncryptedContent, + info: Option>, + ) -> Self { + Self { url, info, encryption_info: Some(Box::new(encryption_info)) } + } + + /// Whether the file is encrypted. + pub fn is_encrypted(&self) -> bool { + self.encryption_info.is_some() + } +} + +/// The payload for an extensible text message. +#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +#[ruma_event(type = "m.file", kind = MessageLike)] +pub struct FileEventContent { + /// The text representation of the message. + #[serde(flatten)] + pub message: MessageContent, + + /// The file content of the message. + #[serde(rename = "org.matrix.msc1767.file")] + pub file: FileContent, + + /// Information about related messages for [rich replies]. + /// + /// [rich replies]: https://spec.matrix.org/v1.2/client-server-api/#rich-replies + #[serde(flatten, skip_serializing_if = "Option::is_none")] + pub relates_to: Option, +} + +impl FileEventContent { + /// Creates a new non-encrypted `FileEventContent` with the given plain text message, url and + /// file info. + pub fn plain( + message: impl Into, + url: Box, + info: Option>, + ) -> Self { + Self { + message: MessageContent::plain(message), + file: FileContent::plain(url, info), + relates_to: None, + } + } + + /// Creates a new non-encrypted `FileEventContent` with the given message, url and + /// file info. + pub fn plain_message( + message: MessageContent, + url: Box, + info: Option>, + ) -> Self { + Self { message, file: FileContent::plain(url, info), relates_to: None } + } + + /// Creates a new encrypted `FileEventContent` with the given plain text message, url, + /// encryption info and file info. + pub fn encrypted( + message: impl Into, + url: Box, + encryption_info: EncryptedContent, + info: Option>, + ) -> Self { + Self { + message: MessageContent::plain(message), + file: FileContent::encrypted(url, encryption_info, info), + relates_to: None, + } + } + + /// Creates a new encrypted `FileEventContent` with the given message, url, + /// encryption info and file info. + pub fn encrypted_message( + message: MessageContent, + url: Box, + encryption_info: EncryptedContent, + info: Option>, + ) -> Self { + Self { message, file: FileContent::encrypted(url, encryption_info, info), relates_to: None } + } +} diff --git a/crates/ruma-events/src/lib.rs b/crates/ruma-events/src/lib.rs index 0e6a858f..9ded1f60 100644 --- a/crates/ruma-events/src/lib.rs +++ b/crates/ruma-events/src/lib.rs @@ -174,6 +174,8 @@ pub mod direct; pub mod dummy; #[cfg(feature = "unstable-msc1767")] pub mod emote; +#[cfg(feature = "unstable-msc3551")] +pub mod file; pub mod forwarded_room_key; pub mod fully_read; pub mod ignored_user_list; diff --git a/crates/ruma-events/tests/file.rs b/crates/ruma-events/tests/file.rs new file mode 100644 index 00000000..7b667d8b --- /dev/null +++ b/crates/ruma-events/tests/file.rs @@ -0,0 +1,251 @@ +#![cfg(feature = "unstable-msc3551")] + +use assign::assign; +use js_int::uint; +use matches::assert_matches; +use ruma_common::MilliSecondsSinceUnixEpoch; +use ruma_events::{ + file::{EncryptedContentInit, FileContent, FileContentInfo, FileEventContent}, + message::MessageContent, + room::{ + message::{InReplyTo, Relation}, + JsonWebKeyInit, + }, + AnyMessageLikeEvent, MessageLikeEvent, Unsigned, +}; +use ruma_identifiers::{event_id, mxc_uri, room_id, user_id}; +use ruma_serde::Base64; +use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; + +#[test] +fn plain_content_serialization() { + let event_content = FileEventContent::plain( + "Upload: my_file.txt", + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + None, + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + } + }) + ); +} + +#[test] +fn encrypted_content_serialization() { + let event_content = FileEventContent::encrypted( + "Upload: my_file.txt", + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + EncryptedContentInit { + 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(), + None, + ); + + assert_eq!( + to_json_value(&event_content).unwrap(), + json!({ + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.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 file_event_serialization() { + let event = MessageLikeEvent { + content: assign!( + FileEventContent::plain_message( + MessageContent::html( + "Upload: my_file.txt", + "Upload: my_file.txt", + ), + mxc_uri!("mxc://notareal.hs/abcdef").to_owned(), + Some(Box::new(assign!( + FileContentInfo::new(), + { + name: Some("my_file.txt".to_owned()), + mimetype: Some("text/plain".to_owned()), + size: Some(uint!(774)), + } + ))), + ), + { + relates_to: Some(Relation::Reply { + in_reply_to: InReplyTo::new(event_id!("$replyevent:example.com").to_owned()), + }), + } + ), + event_id: event_id!("$event:notareal.hs").to_owned(), + sender: user_id!("@user:notareal.hs").to_owned(), + origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(134_829_848)), + room_id: room_id!("!roomid:notareal.hs").to_owned(), + unsigned: Unsigned::default(), + }; + + assert_eq!( + to_json_value(&event).unwrap(), + json!({ + "content": { + "org.matrix.msc1767.message": [ + { "body": "Upload: my_file.txt", "mimetype": "text/html"}, + { "body": "Upload: my_file.txt", "mimetype": "text/plain"}, + ], + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "name": "my_file.txt", + "mimetype": "text/plain", + "size": 774, + }, + "m.relates_to": { + "m.in_reply_to": { + "event_id": "$replyevent:example.com" + } + } + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.file", + }) + ); +} + +#[test] +fn plain_content_deserialization() { + let json_data = json!({ + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + } + }); + + assert_matches!( + from_json_value::(json_data) + .unwrap(), + FileEventContent { message, file, .. } + if message.find_plain() == Some("Upload: my_file.txt") + && message.find_html().is_none() + && file.url.as_str() == "mxc://notareal.hs/abcdef" + ); +} + +#[test] +fn encrypted_content_deserialization() { + let json_data = json!({ + "org.matrix.msc1767.text": "Upload: my_file.txt", + "org.matrix.msc1767.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!( + from_json_value::(json_data) + .unwrap(), + FileEventContent { message, file, .. } + if message.find_plain() == Some("Upload: my_file.txt") + && message.find_html().is_none() + && file.url.as_str() == "mxc://notareal.hs/abcdef" + && file.encryption_info.is_some() + ); +} + +#[test] +fn message_event_deserialization() { + let json_data = json!({ + "content": { + "org.matrix.msc1767.message": [ + { "body": "Upload: my_file.txt", "mimetype": "text/html"}, + { "body": "Upload: my_file.txt", "mimetype": "text/plain"}, + ], + "org.matrix.msc1767.file": { + "url": "mxc://notareal.hs/abcdef", + "name": "my_file.txt", + "mimetype": "text/plain", + "size": 774, + }, + }, + "event_id": "$event:notareal.hs", + "origin_server_ts": 134_829_848, + "room_id": "!roomid:notareal.hs", + "sender": "@user:notareal.hs", + "type": "m.file", + }); + + assert_matches!( + from_json_value::(json_data).unwrap(), + AnyMessageLikeEvent::File(MessageLikeEvent { + content: FileEventContent { + message, + file: FileContent { + url, + info: Some(info), + .. + }, + .. + }, + event_id, + origin_server_ts, + room_id, + sender, + unsigned + }) if event_id == event_id!("$event:notareal.hs") + && message.find_plain() == Some("Upload: my_file.txt") + && message.find_html() == Some("Upload: my_file.txt") + && url.as_str() == "mxc://notareal.hs/abcdef" + && info.name.as_deref() == Some("my_file.txt") + && info.mimetype.as_deref() == Some("text/plain") + && info.size == Some(uint!(774)) + && origin_server_ts == MilliSecondsSinceUnixEpoch(uint!(134_829_848)) + && room_id == room_id!("!roomid:notareal.hs") + && sender == user_id!("@user:notareal.hs") + && unsigned.is_empty() + ); +} diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index 283415db..86fb7268 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -124,6 +124,7 @@ unstable-msc2677 = [ "ruma-common/unstable-msc2677", "ruma-events/unstable-msc2677", ] +unstable-msc3551 = ["ruma-events/unstable-msc3551"] unstable-msc3618 = ["ruma-federation-api/unstable-msc3618"] # Private feature, only used in test / benchmarking code @@ -135,6 +136,7 @@ __ci = [ "unstable-msc2675", "unstable-msc2676", "unstable-msc2677", + "unstable-msc3551", "unstable-msc3618", "ruma-state-res/__ci", ]