From 7a5d9b6e8b566d4a180e7298c7003b8eead90583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 10 Aug 2023 15:22:06 +0200 Subject: [PATCH] events: Add support for redacts key into content of RoomRedactionEvent According to MSC2174 --- crates/ruma-common/CHANGELOG.md | 5 + .../ruma-common/src/events/room/redaction.rs | 249 ++++++++++++++---- .../src/events/room/redaction/event_serde.rs | 105 ++++++++ crates/ruma-common/tests/events/redacted.rs | 2 +- crates/ruma-common/tests/events/redaction.rs | 46 +++- 5 files changed, 358 insertions(+), 49 deletions(-) create mode 100644 crates/ruma-common/src/events/room/redaction/event_serde.rs diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 37ec5eb2..d034ca93 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -24,6 +24,11 @@ Breaking changes: (MSC2746 / Matrix 1.7) - The `Replacement` relation for `RoomMessageEventContent` now takes a `RoomMessageEventContentWithoutRelation` instead of a `MessageType` +- Make the `redacts` field of `Original(Sync)RoomRedactionEvent` optional to handle the format + where the `redacts` key is moved inside the `content`, as introduced in room version 11, + according to MSC2174 / MSC3820 + - `RoomRedactionEventContent::new()` was renamed to `new_v1()`, and `with_reason()` is no + longer a constructor but a builder-type method Improvements: diff --git a/crates/ruma-common/src/events/room/redaction.rs b/crates/ruma-common/src/events/room/redaction.rs index a062dfe8..6d6d335b 100644 --- a/crates/ruma-common/src/events/room/redaction.rs +++ b/crates/ruma-common/src/events/room/redaction.rs @@ -4,19 +4,21 @@ use js_int::Int; use ruma_macros::{Event, EventContent}; -use serde::{Deserialize, Deserializer, Serialize}; -use serde_json::value::RawValue as RawJsonValue; +use serde::{Deserialize, Serialize}; +use tracing::error; use crate::{ events::{ - BundledMessageLikeRelations, EventContent, MessageLikeEventType, RedactedUnsigned, - RedactionDeHelper, + BundledMessageLikeRelations, EventContent, MessageLikeEventType, RedactContent, + RedactedMessageLikeEventContent, RedactedUnsigned, StaticEventContent, }, - serde::{from_raw_json_value, CanBeEmpty}, + serde::CanBeEmpty, EventId, MilliSecondsSinceUnixEpoch, OwnedEventId, OwnedRoomId, OwnedTransactionId, - OwnedUserId, RoomId, UserId, + OwnedUserId, RoomId, RoomVersionId, UserId, }; +mod event_serde; + /// A possibly-redacted redaction event. #[allow(clippy::exhaustive_enums)] #[derive(Clone, Debug)] @@ -40,14 +42,16 @@ pub enum SyncRoomRedactionEvent { } /// Redaction event. -#[derive(Clone, Debug, Event)] +#[derive(Clone, Debug)] #[allow(clippy::exhaustive_structs)] pub struct OriginalRoomRedactionEvent { /// Data specific to the event type. pub content: RoomRedactionEventContent, /// The ID of the event that was redacted. - pub redacts: OwnedEventId, + /// + /// This field is required in room versions prior to 11. + pub redacts: Option, /// The globally unique event identifier for the user who sent the event. pub event_id: OwnedEventId, @@ -65,6 +69,22 @@ pub struct OriginalRoomRedactionEvent { pub unsigned: RoomRedactionUnsigned, } +impl From for OriginalSyncRoomRedactionEvent { + fn from(value: OriginalRoomRedactionEvent) -> Self { + let OriginalRoomRedactionEvent { + content, + redacts, + event_id, + sender, + origin_server_ts, + unsigned, + .. + } = value; + + Self { content, redacts, event_id, sender, origin_server_ts, unsigned } + } +} + /// Redacted redaction event. #[derive(Clone, Debug, Event)] #[allow(clippy::exhaustive_structs)] @@ -89,14 +109,16 @@ pub struct RedactedRoomRedactionEvent { } /// Redaction event without a `room_id`. -#[derive(Clone, Debug, Event)] +#[derive(Clone, Debug)] #[allow(clippy::exhaustive_structs)] pub struct OriginalSyncRoomRedactionEvent { /// Data specific to the event type. pub content: RoomRedactionEventContent, /// The ID of the event that was redacted. - pub redacts: OwnedEventId, + /// + /// This field is required in room versions prior to 11. + pub redacts: Option, /// The globally unique event identifier for the user who sent the event. pub event_id: OwnedEventId, @@ -112,6 +134,21 @@ pub struct OriginalSyncRoomRedactionEvent { } impl OriginalSyncRoomRedactionEvent { + /// Convert this sync event into a full event, one with a `room_id` field. + pub fn into_full_event(self, room_id: OwnedRoomId) -> OriginalRoomRedactionEvent { + let Self { content, redacts, event_id, sender, origin_server_ts, unsigned } = self; + + OriginalRoomRedactionEvent { + content, + redacts, + event_id, + sender, + origin_server_ts, + room_id, + unsigned, + } + } + pub(crate) fn into_maybe_redacted(self) -> SyncRoomRedactionEvent { SyncRoomRedactionEvent::Original(self) } @@ -140,25 +177,85 @@ pub struct RedactedSyncRoomRedactionEvent { /// A redaction of an event. #[derive(Clone, Debug, Default, Deserialize, Serialize, EventContent)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] -#[ruma_event(type = "m.room.redaction", kind = MessageLike)] +#[ruma_event(type = "m.room.redaction", kind = MessageLike, custom_redacted)] pub struct RoomRedactionEventContent { + /// The ID of the event that was redacted. + /// + /// This field is required starting from room version 11. + #[serde(skip_serializing_if = "Option::is_none")] + pub redacts: Option, + /// The reason for the redaction, if any. #[serde(skip_serializing_if = "Option::is_none")] pub reason: Option, } impl RoomRedactionEventContent { - /// Creates an empty `RoomRedactionEventContent`. - pub fn new() -> Self { + /// Creates an empty `RoomRedactionEventContent` according to room versions 1 through 10. + pub fn new_v1() -> Self { Self::default() } - /// Creates a new `RoomRedactionEventContent` with the given reason. - pub fn with_reason(reason: String) -> Self { - Self { reason: Some(reason) } + /// Creates a `RoomRedactionEventContent` with the required `redacts` field introduced in room + /// version 11. + pub fn new_v11(redacts: OwnedEventId) -> Self { + Self { redacts: Some(redacts), ..Default::default() } + } + + /// Add the given reason to this `RoomRedactionEventContent`. + pub fn with_reason(mut self, reason: String) -> Self { + self.reason = Some(reason); + self } } +impl RedactContent for RoomRedactionEventContent { + type Redacted = RedactedRoomRedactionEventContent; + + fn redact(self, version: &RoomVersionId) -> Self::Redacted { + let redacts = match version { + RoomVersionId::V1 + | RoomVersionId::V2 + | RoomVersionId::V3 + | RoomVersionId::V4 + | RoomVersionId::V5 + | RoomVersionId::V6 + | RoomVersionId::V7 + | RoomVersionId::V8 + | RoomVersionId::V9 + | RoomVersionId::V10 => None, + _ => self.redacts, + }; + + RedactedRoomRedactionEventContent { redacts } + } +} + +/// A redacted redaction event. +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] +pub struct RedactedRoomRedactionEventContent { + /// The ID of the event that was redacted. + /// + /// This field is required starting from room version 11. + #[serde(skip_serializing_if = "Option::is_none")] + pub redacts: Option, +} + +impl EventContent for RedactedRoomRedactionEventContent { + type EventType = MessageLikeEventType; + + fn event_type(&self) -> Self::EventType { + MessageLikeEventType::RoomRedaction + } +} + +impl StaticEventContent for RedactedRoomRedactionEventContent { + const TYPE: &'static str = "m.room.redaction"; +} + +impl RedactedMessageLikeEventContent for RedactedRoomRedactionEventContent {} + impl RoomRedactionEvent { /// Returns the `type` of this event. pub fn event_type(&self) -> MessageLikeEventType { @@ -200,6 +297,19 @@ impl RoomRedactionEvent { } } + /// Returns the ID of the event that this event redacts, according to the given room version. + /// + /// # Panics + /// + /// Panics if this is a non-redacted event and both `redacts` field are `None`, which is only + /// possible if the event was modified after being deserialized. + pub fn redacts(&self, room_version: &RoomVersionId) -> Option<&EventId> { + match self { + Self::Original(ev) => Some(ev.redacts(room_version)), + Self::Redacted(ev) => ev.content.redacts.as_deref(), + } + } + /// Get the inner `RoomRedactionEvent` if this is an unredacted event. pub fn as_original(&self) -> Option<&OriginalRoomRedactionEvent> { match self { @@ -209,22 +319,6 @@ impl RoomRedactionEvent { } } -impl<'de> Deserialize<'de> for RoomRedactionEvent { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let json = Box::::deserialize(deserializer)?; - let RedactionDeHelper { unsigned } = from_raw_json_value(&json)?; - - if unsigned.and_then(|u| u.redacted_because).is_some() { - Ok(Self::Redacted(from_raw_json_value(&json)?)) - } else { - Ok(Self::Original(from_raw_json_value(&json)?)) - } - } -} - impl SyncRoomRedactionEvent { /// Returns the `type` of this event. pub fn event_type(&self) -> MessageLikeEventType { @@ -258,6 +352,19 @@ impl SyncRoomRedactionEvent { } } + /// Returns the ID of the event that this event redacts, according to the given room version. + /// + /// # Panics + /// + /// Panics if this is a non-redacted event and both `redacts` field are `None`, which is only + /// possible if the event was modified after being deserialized. + pub fn redacts(&self, room_version: &RoomVersionId) -> Option<&EventId> { + match self { + Self::Original(ev) => Some(ev.redacts(room_version)), + Self::Redacted(ev) => ev.content.redacts.as_deref(), + } + } + /// Get the inner `SyncRoomRedactionEvent` if this is an unredacted event. pub fn as_original(&self) -> Option<&OriginalSyncRoomRedactionEvent> { match self { @@ -284,19 +391,35 @@ impl From for SyncRoomRedactionEvent { } } -impl<'de> Deserialize<'de> for SyncRoomRedactionEvent { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - let json = Box::::deserialize(deserializer)?; - let RedactionDeHelper { unsigned } = from_raw_json_value(&json)?; +impl OriginalRoomRedactionEvent { + /// Returns the ID of the event that this event redacts, according to the proper `redacts` field + /// for the given room version. + /// + /// If the `redacts` field is not the proper one for the given room version, this falls back to + /// the one that is available. + /// + /// # Panics + /// + /// Panics if both `redacts` field are `None`, which is only possible if the event was modified + /// after being deserialized. + pub fn redacts(&self, room_version: &RoomVersionId) -> &EventId { + redacts(room_version, self.redacts.as_deref(), self.content.redacts.as_deref()) + } +} - if unsigned.and_then(|u| u.redacted_because).is_some() { - Ok(Self::Redacted(from_raw_json_value(&json)?)) - } else { - Ok(Self::Original(from_raw_json_value(&json)?)) - } +impl OriginalSyncRoomRedactionEvent { + /// Returns the ID of the event that this event redacts, according to the proper `redacts` field + /// for the given room version. + /// + /// If the `redacts` field is not the proper one for the given room version, this falls back to + /// the one that is available. + /// + /// # Panics + /// + /// Panics if both `redacts` field are `None`, which is only possible if the event was modified + /// after being deserialized. + pub fn redacts(&self, room_version: &RoomVersionId) -> &EventId { + redacts(room_version, self.redacts.as_deref(), self.content.redacts.as_deref()) } } @@ -345,3 +468,41 @@ impl CanBeEmpty for RoomRedactionUnsigned { self.age.is_none() && self.transaction_id.is_none() && self.relations.is_empty() } } + +/// Returns the value of the proper `redacts` field for the given room version. +/// +/// If the `redacts` field is not the proper one for the given room version, this falls back to +/// the one that is available. +/// +/// # Panics +/// +/// Panics if both `redacts` and `content_redacts` are `None`. +fn redacts<'a>( + room_version: &'_ RoomVersionId, + redacts: Option<&'a EventId>, + content_redacts: Option<&'a EventId>, +) -> &'a EventId { + match room_version { + RoomVersionId::V1 + | RoomVersionId::V2 + | RoomVersionId::V3 + | RoomVersionId::V4 + | RoomVersionId::V5 + | RoomVersionId::V6 + | RoomVersionId::V7 + | RoomVersionId::V8 + | RoomVersionId::V9 + | RoomVersionId::V10 => redacts + .or_else(|| { + error!("Redacts field at event level not available, falling back to the one inside content"); + content_redacts + }) + .expect("At least one redacts field is set"), + _ => content_redacts + .or_else(|| { + error!("Redacts field inside content not available, falling back to the one at the event level"); + redacts + }) + .expect("At least one redacts field is set"), + } +} diff --git a/crates/ruma-common/src/events/room/redaction/event_serde.rs b/crates/ruma-common/src/events/room/redaction/event_serde.rs new file mode 100644 index 00000000..045882f0 --- /dev/null +++ b/crates/ruma-common/src/events/room/redaction/event_serde.rs @@ -0,0 +1,105 @@ +use serde::{de, Deserialize, Deserializer}; +use serde_json::value::RawValue as RawJsonValue; + +use super::{ + OriginalRoomRedactionEvent, OriginalSyncRoomRedactionEvent, RoomRedactionEvent, + RoomRedactionEventContent, RoomRedactionUnsigned, SyncRoomRedactionEvent, +}; +use crate::{ + events::RedactionDeHelper, serde::from_raw_json_value, MilliSecondsSinceUnixEpoch, + OwnedEventId, OwnedRoomId, OwnedUserId, +}; + +impl<'de> Deserialize<'de> for RoomRedactionEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let json = Box::::deserialize(deserializer)?; + let RedactionDeHelper { unsigned } = from_raw_json_value(&json)?; + + if unsigned.and_then(|u| u.redacted_because).is_some() { + Ok(Self::Redacted(from_raw_json_value(&json)?)) + } else { + Ok(Self::Original(from_raw_json_value(&json)?)) + } + } +} + +impl<'de> Deserialize<'de> for SyncRoomRedactionEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let json = Box::::deserialize(deserializer)?; + let RedactionDeHelper { unsigned } = from_raw_json_value(&json)?; + + if unsigned.and_then(|u| u.redacted_because).is_some() { + Ok(Self::Redacted(from_raw_json_value(&json)?)) + } else { + Ok(Self::Original(from_raw_json_value(&json)?)) + } + } +} + +#[derive(Deserialize)] +struct OriginalRoomRedactionEventDeHelper { + content: RoomRedactionEventContent, + redacts: Option, + event_id: OwnedEventId, + sender: OwnedUserId, + origin_server_ts: MilliSecondsSinceUnixEpoch, + room_id: Option, + #[serde(default)] + unsigned: RoomRedactionUnsigned, +} + +impl<'de> Deserialize<'de> for OriginalRoomRedactionEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let json = Box::::deserialize(deserializer)?; + let OriginalRoomRedactionEventDeHelper { + content, + redacts, + event_id, + sender, + origin_server_ts, + room_id, + unsigned, + } = from_raw_json_value(&json)?; + + let Some(room_id) = room_id else { return Err(de::Error::missing_field("room_id")) }; + + if redacts.is_none() && content.redacts.is_none() { + return Err(de::Error::missing_field("redacts")); + } + + Ok(Self { content, redacts, event_id, sender, origin_server_ts, room_id, unsigned }) + } +} + +impl<'de> Deserialize<'de> for OriginalSyncRoomRedactionEvent { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let json = Box::::deserialize(deserializer)?; + let OriginalRoomRedactionEventDeHelper { + content, + redacts, + event_id, + sender, + origin_server_ts, + unsigned, + .. + } = from_raw_json_value(&json)?; + + if redacts.is_none() && content.redacts.is_none() { + return Err(de::Error::missing_field("redacts")); + } + + Ok(Self { content, redacts, event_id, sender, origin_server_ts, unsigned }) + } +} diff --git a/crates/ruma-common/tests/events/redacted.rs b/crates/ruma-common/tests/events/redacted.rs index c730b69e..2e255228 100644 --- a/crates/ruma-common/tests/events/redacted.rs +++ b/crates/ruma-common/tests/events/redacted.rs @@ -22,7 +22,7 @@ fn unsigned() -> JsonValue { json!({ "redacted_because": { "type": "m.room.redaction", - "content": RoomRedactionEventContent::with_reason("redacted because".into()), + "content": RoomRedactionEventContent::new_v1().with_reason("redacted because".into()), "redacts": "$h29iv0s8:example.com", "event_id": "$h29iv0s8:example.com", "origin_server_ts": 1, diff --git a/crates/ruma-common/tests/events/redaction.rs b/crates/ruma-common/tests/events/redaction.rs index f3bc4305..eae16e6e 100644 --- a/crates/ruma-common/tests/events/redaction.rs +++ b/crates/ruma-common/tests/events/redaction.rs @@ -5,14 +5,15 @@ use ruma_common::{ room::redaction::{RoomRedactionEvent, RoomRedactionEventContent}, AnyMessageLikeEvent, }, + owned_event_id, serde::CanBeEmpty, - MilliSecondsSinceUnixEpoch, + MilliSecondsSinceUnixEpoch, RoomVersionId, }; use serde_json::{from_value as from_json_value, json, to_value as to_json_value}; #[test] fn serialize_redaction_content() { - let content = RoomRedactionEventContent::with_reason("being very unfriendly".into()); + let content = RoomRedactionEventContent::new_v1().with_reason("being very unfriendly".into()); let actual = to_json_value(content).unwrap(); let expected = json!({ @@ -22,13 +23,29 @@ fn serialize_redaction_content() { assert_eq!(actual, expected); } +#[test] +fn serialize_redaction_content_v11() { + let redacts = owned_event_id!("$abcdef"); + let content = RoomRedactionEventContent::new_v11(redacts.clone()) + .with_reason("being very unfriendly".into()); + + let actual = to_json_value(content).unwrap(); + let expected = json!({ + "redacts": redacts, + "reason": "being very unfriendly" + }); + + assert_eq!(actual, expected); +} + #[test] fn deserialize_redaction() { let json_data = json!({ "content": { + "redacts": "$nomorev11:example.com", "reason": "being very unfriendly" }, - "redacts": "$nomore:example.com", + "redacts": "$nomorev1:example.com", "event_id": "$h29iv0s8:example.com", "sender": "@carl:example.com", "origin_server_ts": 1, @@ -40,11 +57,32 @@ fn deserialize_redaction() { from_json_value::(json_data), Ok(AnyMessageLikeEvent::RoomRedaction(RoomRedactionEvent::Original(ev))) ); + + assert_eq!(ev.redacts(&RoomVersionId::V1), "$nomorev1:example.com"); + assert_eq!(ev.redacts(&RoomVersionId::V11), "$nomorev11:example.com"); + + assert_eq!(ev.content.redacts.unwrap(), "$nomorev11:example.com"); assert_eq!(ev.content.reason.as_deref(), Some("being very unfriendly")); assert_eq!(ev.event_id, "$h29iv0s8:example.com"); - assert_eq!(ev.redacts, "$nomore:example.com"); + assert_eq!(ev.redacts.unwrap(), "$nomorev1:example.com"); assert_eq!(ev.origin_server_ts, MilliSecondsSinceUnixEpoch(uint!(1))); assert_eq!(ev.room_id, "!roomid:room.com"); assert_eq!(ev.sender, "@carl:example.com"); assert!(ev.unsigned.is_empty()); } + +#[test] +fn deserialize_redaction_missing_redacts() { + let json_data = json!({ + "content": { + "reason": "being very unfriendly" + }, + "event_id": "$h29iv0s8:example.com", + "sender": "@carl:example.com", + "origin_server_ts": 1, + "room_id": "!roomid:room.com", + "type": "m.room.redaction" + }); + + from_json_value::(json_data).unwrap_err(); +}