mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-11-15 13:16:56 +00:00
feat(sync): Add MSC4354 sticky events
This commit is contained in:
1 parent
7789399ba5
commit
43197f695c
25 files changed
+572
-18
No files matched your search
@@ -1526,6 +1526,14 @@ pub struct Config {
|
||||
#[serde(default = "true_fn")]
|
||||
pub allow_incoming_typing: bool,
|
||||
|
||||
/// Allow the use of unstable persistent "sticky events" ([MSC4354])
|
||||
///
|
||||
/// MSC4354: https://c10y.cc/MSC4354
|
||||
///
|
||||
/// default: false
|
||||
#[serde(default)]
|
||||
pub allow_sticky_events: bool,
|
||||
|
||||
/// Maximum time federation user can indicate typing.
|
||||
///
|
||||
/// default: 30
|
||||
|
||||
@@ -181,6 +181,10 @@ pub trait Event: Clone + Debug {
|
||||
/// The state key for this event.
|
||||
fn state_key(&self) -> Option<&str>;
|
||||
|
||||
/// The MSC4354 sticky object, verbatim, if the event has one.
|
||||
#[inline]
|
||||
fn sticky(&self) -> Option<&RawJsonValue> { None }
|
||||
|
||||
/// The event type.
|
||||
fn kind(&self) -> &TimelineEventType;
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ use ruma::{
|
||||
use serde_json::json;
|
||||
|
||||
use super::{Event, redact};
|
||||
use crate::matrix::pdu::sticky;
|
||||
|
||||
pub struct Owned<E: Event>(pub(super) E);
|
||||
|
||||
@@ -36,6 +37,9 @@ impl<'a, E: Event> From<Ref<'a, E>> for Raw<AnySyncTimelineEvent> {
|
||||
if let Some(state_key) = event.state_key() {
|
||||
json["state_key"] = json!(state_key);
|
||||
}
|
||||
if let Some(sticky_object) = event.sticky() {
|
||||
json[sticky::PDU_KEY] = json!(sticky_object);
|
||||
}
|
||||
if let Some(unsigned) = event.unsigned() {
|
||||
json["unsigned"] = json!(unsigned);
|
||||
}
|
||||
@@ -67,6 +71,9 @@ impl<'a, E: Event> From<Ref<'a, E>> for Raw<AnyTimelineEvent> {
|
||||
if let Some(state_key) = event.state_key() {
|
||||
json["state_key"] = json!(state_key);
|
||||
}
|
||||
if let Some(sticky_object) = event.sticky() {
|
||||
json[sticky::PDU_KEY] = json!(sticky_object);
|
||||
}
|
||||
if let Some(unsigned) = event.unsigned() {
|
||||
json["unsigned"] = json!(unsigned);
|
||||
}
|
||||
@@ -98,6 +105,9 @@ impl<'a, E: Event> From<Ref<'a, E>> for Raw<AnyMessageLikeEvent> {
|
||||
if let Some(state_key) = event.state_key() {
|
||||
json["state_key"] = json!(state_key);
|
||||
}
|
||||
if let Some(sticky_object) = event.sticky() {
|
||||
json[sticky::PDU_KEY] = json!(sticky_object);
|
||||
}
|
||||
if let Some(unsigned) = event.unsigned() {
|
||||
json["unsigned"] = json!(unsigned);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ mod id;
|
||||
mod partial;
|
||||
mod raw_id;
|
||||
mod redact;
|
||||
pub mod sticky;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
mod unsigned;
|
||||
@@ -46,6 +47,11 @@ pub struct Pdu {
|
||||
|
||||
pub content: Box<RawJsonValue>,
|
||||
|
||||
/// MSC4354 sticky object, kept verbatim because it is signed. Interpret it
|
||||
/// with [`sticky`], never by rewriting it.
|
||||
#[serde(rename = "msc4354_sticky", default, skip_serializing_if = "Option::is_none")]
|
||||
pub sticky: Option<Box<RawJsonValue>>,
|
||||
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub state_key: Option<StateKey>,
|
||||
|
||||
@@ -137,6 +143,9 @@ impl Event for Pdu {
|
||||
#[inline]
|
||||
fn state_key(&self) -> Option<&str> { self.state_key.as_deref() }
|
||||
|
||||
#[inline]
|
||||
fn sticky(&self) -> Option<&RawJsonValue> { self.sticky.as_deref() }
|
||||
|
||||
#[inline]
|
||||
fn kind(&self) -> &TimelineEventType { &self.kind }
|
||||
|
||||
@@ -208,6 +217,9 @@ impl Event for &Pdu {
|
||||
#[inline]
|
||||
fn state_key(&self) -> Option<&str> { self.state_key.as_deref() }
|
||||
|
||||
#[inline]
|
||||
fn sticky(&self) -> Option<&RawJsonValue> { self.sticky.as_deref() }
|
||||
|
||||
#[inline]
|
||||
fn kind(&self) -> &TimelineEventType { &self.kind }
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct PartialPdu {
|
||||
|
||||
pub content: Box<RawJsonValue>,
|
||||
|
||||
pub sticky: Option<Box<RawJsonValue>>,
|
||||
|
||||
pub unsigned: Option<Unsigned>,
|
||||
|
||||
pub state_key: Option<StateKey>,
|
||||
@@ -66,6 +68,7 @@ impl Default for PartialPdu {
|
||||
Self {
|
||||
event_type: "m.room.message".into(),
|
||||
content: to_raw_value(&json!({})).unwrap(),
|
||||
sticky: None,
|
||||
unsigned: None,
|
||||
state_key: None,
|
||||
redacts: None,
|
||||
|
||||
@@ -11,6 +11,9 @@ impl super::Pdu {
|
||||
|
||||
self.unsigned = None;
|
||||
|
||||
// a redacted sticky event is just a normal event (MSC4354)
|
||||
self.sticky = None;
|
||||
|
||||
let mut content = serde_json::from_str(self.content.get()).map_err(|e| {
|
||||
err!(Request(BadJson("Failed to deserialize content into type: {e}")))
|
||||
})?;
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
//! Sticky event helpers, as defined by [MSC4354].
|
||||
//!
|
||||
//! [MSC4354]: https://github.com/matrix-org/matrix-spec-proposals/pull/4354
|
||||
|
||||
use ruma::{UInt, events::sticky::StickyDurationMs};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{
|
||||
json,
|
||||
value::{RawValue as RawJsonValue, to_raw_value},
|
||||
};
|
||||
|
||||
/// Name of the sticky object at the top level of a PDU. Unstable prefix.
|
||||
pub const PDU_KEY: &str = "msc4354_sticky";
|
||||
|
||||
/// Name of the expiry hint injected into `unsigned`. Unstable prefix.
|
||||
pub const TTL_UNSIGNED_KEY: &str = "msc4354_sticky_duration_ttl_ms";
|
||||
|
||||
/// The longest an event may remain sticky (one hour).
|
||||
#[allow(clippy::as_conversions)]
|
||||
pub const MAX_DURATION_MS: u64 = StickyDurationMs::MAX as u64;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Object {
|
||||
duration_ms: u64,
|
||||
}
|
||||
|
||||
/// Build the sticky object for an event we are creating locally.
|
||||
#[must_use]
|
||||
pub fn object(duration_ms: StickyDurationMs) -> Box<RawJsonValue> {
|
||||
to_raw_value(&json!({ "duration_ms": duration_ms }))
|
||||
.expect("a StickyDurationMs always produces a valid sticky object")
|
||||
}
|
||||
|
||||
/// The sticky duration of `sticky`, clamped to [`MAX_DURATION_MS`], or `None`
|
||||
/// if this is not a valid sticky object.
|
||||
///
|
||||
/// A malformed object leaves the event unsticky rather than invalidating it, so
|
||||
/// a peer more lenient than us cannot split the room DAG.
|
||||
#[must_use]
|
||||
pub fn duration_ms(sticky: &RawJsonValue) -> Option<u64> {
|
||||
serde_json::from_str::<Object>(sticky.get())
|
||||
.ok()
|
||||
.map(|sticky| sticky.duration_ms.min(MAX_DURATION_MS))
|
||||
}
|
||||
|
||||
/// The time at which an event stops being sticky, in milliseconds since the
|
||||
/// unix epoch, or `None` if the event was never sticky.
|
||||
///
|
||||
/// MSC4354 starts the clock at `min(received_ts, origin_server_ts)` so a server
|
||||
/// cannot extend stickiness by dating its events forward. We keep no receive
|
||||
/// time per PDU, so instead we deny stickiness to events dated further ahead
|
||||
/// than any event could still be sticky for.
|
||||
#[must_use]
|
||||
pub fn expires_at(origin_server_ts: UInt, sticky: &RawJsonValue, now: u64) -> Option<u64> {
|
||||
let origin_server_ts = u64::from(origin_server_ts);
|
||||
if origin_server_ts.saturating_sub(now) > MAX_DURATION_MS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let start = origin_server_ts.min(now);
|
||||
duration_ms(sticky).map(|duration_ms| start.saturating_add(duration_ms))
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn is_sticky(origin_server_ts: UInt, sticky: &RawJsonValue, now: u64) -> bool {
|
||||
expires_at(origin_server_ts, sticky, now).is_some_and(|expires_at| expires_at > now)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use ruma::{UInt, events::sticky::StickyDurationMs};
|
||||
use serde_json::value::{RawValue as RawJsonValue, to_raw_value};
|
||||
|
||||
use super::{MAX_DURATION_MS, duration_ms, expires_at, is_sticky, object};
|
||||
|
||||
fn raw(json: &serde_json::Value) -> Box<RawJsonValue> { to_raw_value(json).unwrap() }
|
||||
|
||||
fn ts(millis: u64) -> UInt { UInt::try_from(millis).unwrap() }
|
||||
|
||||
#[test]
|
||||
fn duration_of_valid_object() {
|
||||
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
|
||||
assert_eq!(duration_ms(&sticky), Some(300_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duration_is_clamped_to_one_hour() {
|
||||
let sticky = raw(&serde_json::json!({ "duration_ms": 9_999_999_999_u64 }));
|
||||
assert_eq!(duration_ms(&sticky), Some(MAX_DURATION_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_objects_are_not_sticky() {
|
||||
for malformed in [
|
||||
serde_json::json!({}),
|
||||
serde_json::json!({ "duration_ms": -1 }),
|
||||
serde_json::json!({ "duration_ms": "300000" }),
|
||||
serde_json::json!({ "duration_ms": 3000.5 }),
|
||||
serde_json::json!("nonsense"),
|
||||
serde_json::json!(null),
|
||||
] {
|
||||
let sticky = raw(&malformed);
|
||||
assert_eq!(duration_ms(&sticky), None, "{malformed} should not be sticky");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn object_round_trips_through_duration_ms() {
|
||||
let sticky = object(StickyDurationMs::new_clamped(600_000_u32));
|
||||
assert_eq!(duration_ms(&sticky), Some(600_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_is_origin_server_ts_plus_duration() {
|
||||
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
|
||||
assert_eq!(expires_at(ts(1_000_000), &sticky, 1_100_000), Some(1_300_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expiry_uses_the_clamped_duration() {
|
||||
let sticky = raw(&serde_json::json!({ "duration_ms": 9_999_999_999_u64 }));
|
||||
assert_eq!(
|
||||
expires_at(ts(1_000_000), &sticky, 1_000_000),
|
||||
Some(1_000_000 + MAX_DURATION_MS)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timestamps_far_in_the_future_are_not_sticky() {
|
||||
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
|
||||
let now = 1_000_000;
|
||||
|
||||
// a little ahead of us is tolerated, and starts the clock at `now`
|
||||
let slightly_ahead = ts(now + MAX_DURATION_MS);
|
||||
assert_eq!(expires_at(slightly_ahead, &sticky, now), Some(now + 300_000));
|
||||
|
||||
// beyond the point any honest event could still be sticky, it is not
|
||||
let far_ahead = ts(now + MAX_DURATION_MS + 1);
|
||||
assert_eq!(expires_at(far_ahead, &sticky, now), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stickiness_ends_at_the_expiry() {
|
||||
let sticky = raw(&serde_json::json!({ "duration_ms": 300_000 }));
|
||||
assert!(is_sticky(ts(1_000_000), &sticky, 1_299_999));
|
||||
assert!(!is_sticky(ts(1_000_000), &sticky, 1_300_000));
|
||||
assert!(!is_sticky(ts(1_000_000), &sticky, 1_300_001));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_duration_is_never_sticky() {
|
||||
let sticky = raw(&serde_json::json!({ "duration_ms": 0 }));
|
||||
assert!(!is_sticky(ts(1_000_000), &sticky, 1_000_000));
|
||||
}
|
||||
}
|
||||
@@ -15,3 +15,96 @@ fn normal_parse() {
|
||||
|
||||
assert!(!backfilled, "backfilled variant");
|
||||
}
|
||||
|
||||
fn sticky_pdu(sticky: &serde_json::Value) -> super::Pdu {
|
||||
serde_json::from_value(serde_json::json!({
|
||||
"event_id": "$test:example.com",
|
||||
"room_id": "!test:example.com",
|
||||
"sender": "@test:example.com",
|
||||
"origin_server_ts": 1_000_000,
|
||||
"type": "m.room.message",
|
||||
"content": { "msgtype": "m.text", "body": "test" },
|
||||
"msc4354_sticky": sticky,
|
||||
"prev_events": [],
|
||||
"depth": 1,
|
||||
"auth_events": [],
|
||||
"hashes": { "sha256": "test_hash" },
|
||||
}))
|
||||
.expect("PDU is valid")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sticky_object_round_trips_verbatim() {
|
||||
// the object is signed, so serialization must not touch it
|
||||
let pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000, "unknown": "key" }));
|
||||
let value = serde_json::to_value(&pdu).expect("PDU serializes");
|
||||
|
||||
assert_eq!(
|
||||
value["msc4354_sticky"],
|
||||
serde_json::json!({ "duration_ms": 300_000, "unknown": "key" })
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_range_sticky_object_is_clamped_not_rejected() {
|
||||
let pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 9_999_999_999_u64 }));
|
||||
let sticky = pdu.sticky.as_deref().expect("sticky object is kept");
|
||||
|
||||
assert_eq!(super::sticky::duration_ms(sticky), Some(super::sticky::MAX_DURATION_MS));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_sticky_object_does_not_reject_the_pdu() {
|
||||
let pdu = sticky_pdu(&serde_json::json!("nonsense"));
|
||||
let sticky = pdu.sticky.as_deref().expect("sticky object is kept");
|
||||
|
||||
assert_eq!(super::sticky::duration_ms(sticky), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_removes_stickiness() {
|
||||
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000 }));
|
||||
pdu.redact(&ruma::RoomVersionId::V11, serde_json::json!({}))
|
||||
.expect("redaction succeeds");
|
||||
|
||||
assert!(pdu.sticky.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sticky_ttl_is_added_to_unsigned() {
|
||||
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000 }));
|
||||
pdu.origin_server_ts = ruma::MilliSecondsSinceUnixEpoch::now().get();
|
||||
pdu.add_sticky_duration_ttl().expect("ttl is added");
|
||||
|
||||
let unsigned: serde_json::Value =
|
||||
serde_json::from_str(pdu.unsigned.as_deref().expect("unsigned is set").get())
|
||||
.expect("unsigned is valid");
|
||||
let ttl = unsigned["msc4354_sticky_duration_ttl_ms"]
|
||||
.as_u64()
|
||||
.expect("ttl is a number");
|
||||
|
||||
assert!(ttl > 0 && ttl <= 300_000, "ttl {ttl} out of range");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_ttl_for_events_that_are_not_sticky() {
|
||||
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": "nonsense" }));
|
||||
pdu.add_sticky_duration_ttl().expect("ttl is skipped");
|
||||
|
||||
assert!(pdu.unsigned.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_unsigned_adds_the_sticky_ttl() {
|
||||
// sticky events in the timeline get the hint through here, not the sticky
|
||||
// section
|
||||
let mut pdu = sticky_pdu(&serde_json::json!({ "duration_ms": 300_000 }));
|
||||
pdu.origin_server_ts = ruma::MilliSecondsSinceUnixEpoch::now().get();
|
||||
pdu.set_unsigned(None);
|
||||
|
||||
let unsigned: serde_json::Value =
|
||||
serde_json::from_str(pdu.unsigned.as_deref().expect("unsigned is set").get())
|
||||
.expect("unsigned is valid");
|
||||
|
||||
assert!(unsigned["msc4354_sticky_duration_ttl_ms"].is_number());
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use std::{borrow::Borrow, collections::BTreeMap};
|
||||
use ruma::MilliSecondsSinceUnixEpoch;
|
||||
use serde_json::value::{RawValue as RawJsonValue, Value as JsonValue, to_raw_value};
|
||||
|
||||
use super::Pdu;
|
||||
use super::{Pdu, sticky};
|
||||
use crate::{Result, err, result::LogErr};
|
||||
|
||||
impl Pdu {
|
||||
@@ -17,6 +17,7 @@ impl Pdu {
|
||||
self.remove_transaction_id().log_err().ok();
|
||||
}
|
||||
self.add_age().log_err().ok();
|
||||
self.add_sticky_duration_ttl().log_err().ok();
|
||||
}
|
||||
|
||||
pub fn remove_transaction_id(&mut self) -> Result {
|
||||
@@ -58,6 +59,31 @@ impl Pdu {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_sticky_duration_ttl(&mut self) -> Result {
|
||||
use BTreeMap as Map;
|
||||
|
||||
let now = u64::from(MilliSecondsSinceUnixEpoch::now().get());
|
||||
let Some(expires_at) = self
|
||||
.sticky
|
||||
.as_deref()
|
||||
.and_then(|sticky| sticky::expires_at(self.origin_server_ts, sticky, now))
|
||||
else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let mut unsigned: Map<&str, Box<RawJsonValue>> = self
|
||||
.unsigned
|
||||
.as_deref()
|
||||
.map(RawJsonValue::get)
|
||||
.map_or_else(|| Ok(Map::new()), serde_json::from_str)
|
||||
.map_err(|e| err!(Database("Invalid unsigned in pdu event: {e}")))?;
|
||||
|
||||
unsigned.insert(sticky::TTL_UNSIGNED_KEY, to_raw_value(&expires_at.saturating_sub(now))?);
|
||||
self.unsigned = Some(to_raw_value(&unsigned)?);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn add_relation(&mut self, name: &str, pdu: Option<&Self>) -> Result {
|
||||
use serde_json::Map;
|
||||
|
||||
|
||||
@@ -414,6 +414,7 @@ where
|
||||
content,
|
||||
origin: None,
|
||||
redacts: None,
|
||||
sticky: None,
|
||||
unsigned: None,
|
||||
auth_events,
|
||||
prev_events,
|
||||
|
||||
@@ -414,6 +414,7 @@ pub(crate) fn to_init_pdu_event(
|
||||
content,
|
||||
origin: None,
|
||||
redacts: None,
|
||||
sticky: None,
|
||||
unsigned: None,
|
||||
auth_events: vec![],
|
||||
prev_events: vec![],
|
||||
@@ -462,6 +463,7 @@ where
|
||||
content,
|
||||
origin: None,
|
||||
redacts: None,
|
||||
sticky: None,
|
||||
unsigned: None,
|
||||
auth_events,
|
||||
prev_events,
|
||||
|
||||
Reference in new issue
Block a user