Merge remote-tracking branch 'upstream/main' into conduwuit-changes

This commit is contained in:
strawberry 2024-06-30 11:54:39 -04:00
commit 9a5bfad849
81 changed files with 2906 additions and 818 deletions

View File

@ -137,6 +137,13 @@ use super::MyType;
### Commit Messages
The commit message should start with the _area_ that is affected by the change.
An area is usually the name of the affected crate without the `ruma-` prefix,
except for the ruma-common crate, where the area is usually the name of the
top-level module, like `api` or `identifiers`. For example, the description of
a commit that affects the ruma-events crate should look like
"events: Add new event".
Write commit messages using the imperative mood, as if completing the sentence:
"If applied, this commit will \_\_\_." For example, use "Fix some bug" instead
of "Fixed some bug" or "Add a feature" instead of "Added a feature".

View File

@ -33,7 +33,7 @@ pub mod v1 {
/// One or more custom fields to help identify the third party location.
// The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
#[ruma_api(query_map)]
#[ruma_api(query_all)]
pub fields: BTreeMap<String, String>,
}

View File

@ -34,7 +34,7 @@ pub mod v1 {
/// One or more custom fields that are passed to the AS to help identify the user.
// The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
#[ruma_api(query_map)]
#[ruma_api(query_all)]
pub fields: BTreeMap<String, String>,
}

View File

@ -6,6 +6,7 @@ Breaking changes:
as before.
- Change type of `client_secret` field in `ThirdpartyIdCredentials`
from `Box<ClientSecret>` to `OwnedClientSecret`
- Make `id_server` and `id_access_token` in `ThirdpartyIdCredentials` optional
Improvements:
@ -13,15 +14,23 @@ Improvements:
- Heroes in `sync::sync_events::v4`: `SyncRequestList` and `RoomSubscription`
both have a new `include_heroes` field. `SlidingSyncRoom` has a new `heroes`
field, with a new type `SlidingSyncRoomHero`.
- Add unstable support for authenticated media endpoints, according to MSC3916.
Bug fixes:
- Rename `avatar` to `avatar_url` when (De)serializing
- Add support for authenticated media endpoints, according to MSC3916 / Matrix
1.11.
- They replace the newly deprecated `media::get_*` endpoints.
- Stabilize support for animated thumbnails, according to Matrix 1.11
- Add support for terms of service at registration, according to MSC1692 /
Matrix 1.11
- Add unstable support for [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140)
to send `Future` events and update `Future` events with `future_tokens`.
(`Future` events are scheduled messages that can be controlled
with `future_tokens` to send on demand or restart the timeout)
Bug fixes:
- Rename `avatar` to `avatar_url` when (De)serializing `SlidingSyncRoomHero`
- `user_id` of `SlidingSyncRoomHero` is now mandatory
- Make authentication with access token optional for the `change_password` and
`deactivate` endpoints.
# 0.18.0

View File

@ -40,7 +40,6 @@ unstable-exhaustive-types = ["ruma-common/unstable-exhaustive-types"]
unstable-msc2666 = []
unstable-msc2448 = []
unstable-msc2654 = []
unstable-msc2705 = []
unstable-msc2965 = []
unstable-msc2967 = []
unstable-msc3266 = []
@ -48,10 +47,10 @@ unstable-msc3488 = []
unstable-msc3575 = []
unstable-msc3814 = []
unstable-msc3843 = []
unstable-msc3916 = []
unstable-msc3983 = []
unstable-msc4108 = []
unstable-msc4121 = []
unstable-msc4140 = []
[dependencies]
as_variant = { workspace = true }

View File

@ -17,7 +17,7 @@ pub mod v3 {
const METADATA: Metadata = metadata! {
method: POST,
rate_limited: true,
authentication: AccessToken,
authentication: AccessTokenOptional,
history: {
1.0 => "/_matrix/client/r0/account/password",
1.1 => "/_matrix/client/v3/account/password",

View File

@ -20,7 +20,7 @@ pub mod v3 {
const METADATA: Metadata = metadata! {
method: POST,
rate_limited: true,
authentication: AccessToken,
authentication: AccessTokenOptional,
history: {
1.0 => "/_matrix/client/r0/account/deactivate",
1.1 => "/_matrix/client/v3/account/deactivate",

View File

@ -1,6 +1,6 @@
//! Authenticated endpoints for the media repository, according to [MSC3916].
//! Authenticated endpoints for the [content repository].
//!
//! [MSC3916]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
//! [content repository]: https://spec.matrix.org/latest/client-server-api/#content-repository
pub mod get_content;
pub mod get_content_as_filename;

View File

@ -2,10 +2,10 @@
//!
//! Retrieve content from the media store.
pub mod unstable {
//! `/unstable/org.matrix.msc3916/` ([MSC])
pub mod v1 {
//! `/v1/` ([spec])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
//! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediadownloadservernamemediaid
use std::time::Duration;
@ -17,10 +17,11 @@ pub mod unstable {
const METADATA: Metadata = metadata! {
method: GET,
rate_limited: false,
rate_limited: true,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/download/:server_name/:media_id",
1.11 => "/_matrix/client/v1/media/download/:server_name/:media_id",
}
};

View File

@ -2,10 +2,10 @@
//!
//! Retrieve content from the media store, specifying a filename to return.
pub mod unstable {
//! `/unstable/org.matrix.msc3916/` ([MSC])
pub mod v1 {
//! `/v1/` ([spec])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
//! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediadownloadservernamemediaidfilename
use std::time::Duration;
@ -17,10 +17,11 @@ pub mod unstable {
const METADATA: Metadata = metadata! {
method: GET,
rate_limited: false,
rate_limited: true,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/download/:server_name/:media_id/:filename",
1.11 => "/_matrix/client/v1/media/download/:server_name/:media_id/:filename",
}
};

View File

@ -2,10 +2,10 @@
//!
//! Get a thumbnail of content from the media store.
pub mod unstable {
//! `/unstable/org.matrix.msc3916/` ([MSC])
pub mod v1 {
//! `/v1/` ([spec])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
//! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediathumbnailservernamemediaid
use std::time::Duration;
@ -24,6 +24,7 @@ pub mod unstable {
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/:server_name/:media_id",
1.11 => "/_matrix/client/v1/media/thumbnail/:server_name/:media_id",
}
};
@ -69,18 +70,12 @@ pub mod unstable {
/// Whether the server should return an animated thumbnail.
///
/// When `true`, the server should return an animated thumbnail if possible and supported.
/// Otherwise it must not return an animated thumbnail.
///
/// Defaults to `false`.
#[cfg(feature = "unstable-msc2705")]
/// When `Some(true)`, the server should return an animated thumbnail if possible and
/// supported. When `Some(false)`, the server must not return an animated
/// thumbnail. When `None`, the server should not return an animated thumbnail.
#[ruma_api(query)]
#[serde(
rename = "org.matrix.msc2705.animated",
default,
skip_serializing_if = "ruma_common::serde::is_default"
)]
pub animated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub animated: Option<bool>,
}
/// Response type for the `get_content_thumbnail` endpoint.
@ -111,8 +106,7 @@ pub mod unstable {
width,
height,
timeout_ms: crate::media::default_download_timeout(),
#[cfg(feature = "unstable-msc2705")]
animated: false,
animated: None,
}
}

View File

@ -2,10 +2,10 @@
//!
//! Gets the config for the media repository.
pub mod unstable {
//! `/unstable/org.matrix.msc3916/` ([MSC])
pub mod v1 {
//! `/v1/` ([spec])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
//! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediaconfig
use js_int::UInt;
use ruma_common::{
@ -19,6 +19,7 @@ pub mod unstable {
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/config",
1.11 => "/_matrix/client/v1/media/config",
}
};

View File

@ -2,10 +2,10 @@
//!
//! Get a preview for a URL.
pub mod unstable {
//! `/unstable/org.matrix.msc3916/` ([MSC])
pub mod v1 {
//! `/v1/` ([spec])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
//! [spec]: https://spec.matrix.org/latest/client-server-api/#get_matrixclientv1mediapreview_url
use ruma_common::{
api::{request, response, Metadata},
@ -20,6 +20,7 @@ pub mod unstable {
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/preview_url",
1.11 => "/_matrix/client/v1/media/preview_url",
}
};

View File

@ -0,0 +1,38 @@
//! Endpoints for sending and receiving futures
pub mod send_future_message_event;
pub mod send_future_state_event;
pub mod update_future;
use serde::{Deserialize, Serialize};
use web_time::Duration;
/// The query parameters for a future request.
/// It can contain the possible timeout and the future_group_id combinations.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(untagged)]
pub enum FutureParameters {
/// Only sending the timeout creates a timeout future with a new (server generated)
/// group id. The optional group id is used to create a secondary timeout.
/// In a future group with two timeouts only one of them will ever be sent.
Timeout {
/// The timeout duration for this Future.
#[serde(with = "ruma_common::serde::duration::ms")]
#[serde(rename = "future_timeout")]
timeout: Duration,
/// The associated group for this Future.
#[serde(skip_serializing_if = "Option::is_none")]
#[serde(rename = "future_group_id")]
group_id: Option<String>,
},
/// Adds an additional action to a future without a timeout but requires a future group_id.
/// A possible matrix event that this future group can resolve to. It can be sent by using the
/// send_token as an alternative to the timeout future of an already existing group.
Action {
/// The associated group for this Future.
#[serde(rename = "future_group_id")]
group_id: String,
},
}

View File

@ -0,0 +1,182 @@
//! `PUT /_matrix/client/*/rooms/{roomId}/send_future/{eventType}/{txnId}`
//!
//! Send a future (a scheduled message) to a room. [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140)
pub mod unstable {
//! `msc4140` ([MSC])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140
use ruma_common::{
api::{request, response, Metadata},
metadata,
serde::Raw,
OwnedRoomId, OwnedTransactionId,
};
use ruma_events::{AnyMessageLikeEventContent, MessageLikeEventContent, MessageLikeEventType};
use serde_json::value::to_raw_value as to_raw_json_value;
use crate::future::FutureParameters;
const METADATA: Metadata = metadata! {
method: PUT,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc4140/rooms/:room_id/send_future/:event_type/:txn_id",
}
};
/// Request type for the [`send_future_message_event`](crate::future::send_future_message_event)
/// endpoint.
#[request(error = crate::Error)]
pub struct Request {
/// The room to send the event to.
#[ruma_api(path)]
pub room_id: OwnedRoomId,
/// The type of event to send.
#[ruma_api(path)]
pub event_type: MessageLikeEventType,
/// The transaction ID for this event.
///
/// Clients should generate a unique ID across requests within the
/// same session. A session is identified by an access token, and
/// persists when the [access token is refreshed].
///
/// It will be used by the server to ensure idempotency of requests.
///
/// [access token is refreshed]: https://spec.matrix.org/latest/client-server-api/#refreshing-access-tokens
#[ruma_api(path)]
pub txn_id: OwnedTransactionId,
/// Additional parameters to describe sending a future.
///
/// Only three combinations for `future_timeout` and `future_group_id` are possible.
/// The enum [`FutureParameters`] enforces this.
#[ruma_api(query_all)]
pub future_parameters: FutureParameters,
/// The event content to send.
#[ruma_api(body)]
pub body: Raw<AnyMessageLikeEventContent>,
}
/// Response type for the
/// [`send_future_message_event`](crate::future::send_future_message_event) endpoint.
#[response(error = crate::Error)]
pub struct Response {
/// A token to send/insert the future into the DAG.
pub send_token: String,
/// A token to cancel this future. It will never be send if this is called.
pub cancel_token: String,
/// The `future_group_id` generated for this future. Used to connect multiple futures
/// only one of the connected futures will be sent and inserted into the DAG.
pub future_group_id: String,
/// A token used to refresh the timer of the future. This allows
/// to implement heartbeat like capabilities. An event is only sent once
/// a refresh in the timeout interval is missed.
///
/// If the future does not have a timeout this will be `None`.
pub refresh_token: Option<String>,
}
impl Request {
/// Creates a new `Request` with the given room id, transaction id future_parameters and
/// event content.
///
/// # Errors
///
/// Since `Request` stores the request body in serialized form, this function can fail if
/// `T`s [`::serde::Serialize`] implementation can fail.
pub fn new<T>(
room_id: OwnedRoomId,
txn_id: OwnedTransactionId,
future_parameters: FutureParameters,
content: &T,
) -> serde_json::Result<Self>
where
T: MessageLikeEventContent,
{
Ok(Self {
room_id,
txn_id,
event_type: content.event_type(),
future_parameters,
body: Raw::from_json(to_raw_json_value(content)?),
})
}
/// Creates a new `Request` with the given room id, transaction id, event type,
/// future parameters and raw event content.
pub fn new_raw(
room_id: OwnedRoomId,
txn_id: OwnedTransactionId,
event_type: MessageLikeEventType,
future_parameters: FutureParameters,
body: Raw<AnyMessageLikeEventContent>,
) -> Self {
Self { room_id, event_type, txn_id, future_parameters, body }
}
}
impl Response {
/// Creates a new `Response` with the tokens required to control the future using the
/// [`crate::future::update_future::unstable::Request`] request.
pub fn new(
send_token: String,
cancel_token: String,
future_group_id: String,
refresh_token: Option<String>,
) -> Self {
Self { send_token, cancel_token, future_group_id, refresh_token }
}
}
#[cfg(all(test, feature = "client"))]
mod tests {
use ruma_common::{
api::{MatrixVersion, OutgoingRequest, SendAccessToken},
owned_room_id,
};
use ruma_events::room::message::RoomMessageEventContent;
use serde_json::{json, Value as JsonValue};
use web_time::Duration;
use super::Request;
use crate::future::send_future_message_event::unstable::FutureParameters;
#[test]
fn serialize_message_future_request() {
let room_id = owned_room_id!("!roomid:example.org");
let req = Request::new(
room_id,
"1234".into(),
FutureParameters::Timeout {
timeout: Duration::from_millis(103),
group_id: Some("testId".to_owned()),
},
&RoomMessageEventContent::text_plain("test"),
)
.unwrap();
let request: http::Request<Vec<u8>> = req
.try_into_http_request(
"https://homeserver.tld",
SendAccessToken::IfRequired("auth_tok"),
&[MatrixVersion::V1_1],
)
.unwrap();
let (parts, body) = request.into_parts();
assert_eq!(
"https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/rooms/!roomid:example.org/send_future/m.room.message/1234?future_timeout=103&future_group_id=testId",
parts.uri.to_string()
);
assert_eq!("PUT", parts.method.to_string());
assert_eq!(
json!({"msgtype":"m.text","body":"test"}),
serde_json::from_str::<JsonValue>(std::str::from_utf8(&body).unwrap()).unwrap()
);
}
}
}

View File

@ -0,0 +1,175 @@
//! `PUT /_matrix/client/*/rooms/{roomId}/state_future/{eventType}/{txnId}`
//!
//! Send a future state (a scheduled state event) to a room. [MSC](https://github.com/matrix-org/matrix-spec-proposals/pull/4140)
pub mod unstable {
//! `msc4140` ([MSC])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140
use ruma_common::{
api::{request, response, Metadata},
metadata,
serde::Raw,
OwnedRoomId,
};
use ruma_events::{AnyStateEventContent, StateEventContent, StateEventType};
use serde_json::value::to_raw_value as to_raw_json_value;
use crate::future::FutureParameters;
const METADATA: Metadata = metadata! {
method: PUT,
rate_limited: false,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc4140/rooms/:room_id/state_future/:event_type/:state_key",
}
};
/// Request type for the [`send_future_state_event`](crate::future::send_future_state_event)
/// endpoint.
#[request(error = crate::Error)]
pub struct Request {
/// The room to send the event to.
#[ruma_api(path)]
pub room_id: OwnedRoomId,
/// The type of event to send.
#[ruma_api(path)]
pub event_type: StateEventType,
/// The state_key for the state to send.
#[ruma_api(path)]
pub state_key: String,
/// Additional parameters to describe sending a future.
///
/// Only three combinations for `future_timeout` and `future_group_id` are possible.
/// The enum [`FutureParameters`] enforces this.
#[ruma_api(query_all)]
pub future_parameters: FutureParameters,
/// The event content to send.
#[ruma_api(body)]
pub body: Raw<AnyStateEventContent>,
}
/// Response type for the [`send_future_state_event`](crate::future::send_future_state_event)
/// endpoint.
#[response(error = crate::Error)]
pub struct Response {
/// A token to send/insert the future into the DAG.
pub send_token: String,
/// A token to cancel this future. It will never be send if this is called.
pub cancel_token: String,
/// The `future_group_id` generated for this future. Used to connect multiple futures
/// only one of the connected futures will be sent and inserted into the DAG.
pub future_group_id: String,
/// A token used to refresh the timer of the future. This allows
/// to implement heardbeat like capabilities. An event is only send once
/// a refresh in the timeout interval is missed.
///
/// If the future does not have a timeout this will be `None`.
pub refresh_token: Option<String>,
}
impl Request {
/// Creates a new `Request` with the given room id, state_key future_parameters and
/// event content.
///
/// # Errors
///
/// Since `Request` stores the request body in serialized form, this function can fail if
/// `T`s [`::serde::Serialize`] implementation can fail.
pub fn new<T>(
room_id: OwnedRoomId,
state_key: String,
future_parameters: FutureParameters,
content: &T,
) -> serde_json::Result<Self>
where
T: StateEventContent,
{
Ok(Self {
room_id,
state_key,
event_type: content.event_type(),
future_parameters,
body: Raw::from_json(to_raw_json_value(content)?),
})
}
/// Creates a new `Request` with the given room id, event type, state key,
/// future parameters and raw event content.
pub fn new_raw(
room_id: OwnedRoomId,
state_key: String,
event_type: StateEventType,
future_parameters: FutureParameters,
body: Raw<AnyStateEventContent>,
) -> Self {
Self { room_id, event_type, state_key, body, future_parameters }
}
}
impl Response {
/// Creates a new `Response` with the tokens required to control the future using the
/// [`crate::future::update_future::unstable::Request`] request.
pub fn new(
send_token: String,
cancel_token: String,
future_group_id: String,
refresh_token: Option<String>,
) -> Self {
Self { send_token, cancel_token, future_group_id, refresh_token }
}
}
#[cfg(all(test, feature = "client"))]
mod tests {
use ruma_common::{
api::{MatrixVersion, OutgoingRequest, SendAccessToken},
owned_room_id,
};
use ruma_events::room::topic::RoomTopicEventContent;
use serde_json::{json, Value as JsonValue};
use web_time::Duration;
use super::Request;
use crate::future::FutureParameters;
#[test]
fn serialize_state_future_request() {
let room_id = owned_room_id!("!roomid:example.org");
let req = Request::new(
room_id,
"@userAsStateKey:example.org".to_owned(),
FutureParameters::Timeout {
timeout: Duration::from_millis(1_234_321),
group_id: Some("abs1abs1abs1abs1".to_owned()),
},
&RoomTopicEventContent::new("my_topic".to_owned()),
)
.unwrap();
let request: http::Request<Vec<u8>> = req
.try_into_http_request(
"https://homeserver.tld",
SendAccessToken::IfRequired("auth_tok"),
&[MatrixVersion::V1_1],
)
.unwrap();
let (parts, body) = request.into_parts();
assert_eq!(
"https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/rooms/!roomid:example.org/state_future/m.room.topic/@userAsStateKey:example.org?future_timeout=1234321&future_group_id=abs1abs1abs1abs1",
parts.uri.to_string()
);
assert_eq!("PUT", parts.method.to_string());
assert_eq!(
json!({"topic": "my_topic"}),
serde_json::from_str::<JsonValue>(std::str::from_utf8(&body).unwrap()).unwrap()
);
}
}
}

View File

@ -0,0 +1,49 @@
//! `POST /_matrix/client/*/futures/{token}`
//!
//! Send a future token to update/cancel/send the associated future event.
pub mod unstable {
//! `msc3814` ([MSC])
//!
//! [MSC]: https://github.com/matrix-org/matrix-spec-proposals/pull/4140
use ruma_common::{
api::{request, response, Metadata},
metadata,
};
const METADATA: Metadata = metadata! {
method: POST,
rate_limited: true,
authentication: None,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc4140/future/:token",
}
};
/// Request type for the [`update_future`](crate::future::update_future) endpoint.
#[request(error = crate::Error)]
pub struct Request {
/// The token.
#[ruma_api(path)]
pub token: String,
}
impl Request {
/// Creates a new `Request` to update a future. This is an unauthenticated request and only
/// requires the future token.
pub fn new(token: String) -> serde_json::Result<Self> {
Ok(Self { token })
}
}
/// Response type for the [`update_future`](crate::future::update_future) endpoint.
#[response(error = crate::Error)]
pub struct Response {}
impl Response {
/// Creates a new response for the [`update_future`](crate::future::update_future) endpoint.
pub fn new() -> Self {
Response {}
}
}
}

View File

@ -12,7 +12,6 @@
pub mod account;
pub mod alias;
pub mod appservice;
#[cfg(feature = "unstable-msc3916")]
pub mod authenticated_media;
pub mod backup;
pub mod config;
@ -24,6 +23,8 @@ pub mod directory;
pub mod discovery;
pub mod error;
pub mod filter;
#[cfg(feature = "unstable-msc4140")]
pub mod future;
pub mod http_headers;
pub mod keys;
pub mod knock;

View File

@ -24,11 +24,16 @@ pub mod v3 {
history: {
1.0 => "/_matrix/media/r0/download/:server_name/:media_id",
1.1 => "/_matrix/media/v3/download/:server_name/:media_id",
1.11 => deprecated,
}
};
/// Request type for the `get_media_content` endpoint.
#[request(error = crate::Error)]
#[deprecated = "\
Since Matrix 1.11, clients should use `authenticated_media::get_content::v1::Request` \
instead if the homeserver supports it.\
"]
pub struct Request {
/// The server name from the mxc:// URI (the authoritory component).
#[ruma_api(path)]
@ -106,6 +111,7 @@ pub mod v3 {
pub cache_control: Option<String>,
}
#[allow(deprecated)]
impl Request {
/// Creates a new `Request` with the given media ID and server name.
pub fn new(media_id: String, server_name: OwnedServerName) -> Self {

View File

@ -24,11 +24,16 @@ pub mod v3 {
history: {
1.0 => "/_matrix/media/r0/download/:server_name/:media_id/:filename",
1.1 => "/_matrix/media/v3/download/:server_name/:media_id/:filename",
1.11 => deprecated,
}
};
/// Request type for the `get_media_content_as_filename` endpoint.
#[request(error = crate::Error)]
#[deprecated = "\
Since Matrix 1.11, clients should use `authenticated_media::get_content_as_filename::v1::Request` \
instead if the homeserver supports it.\
"]
pub struct Request {
/// The server name from the mxc:// URI (the authoritory component).
#[ruma_api(path)]
@ -110,6 +115,7 @@ pub mod v3 {
pub cache_control: Option<String>,
}
#[allow(deprecated)]
impl Request {
/// Creates a new `Request` with the given media ID, server name and filename.
pub fn new(media_id: String, server_name: OwnedServerName, filename: String) -> Self {

View File

@ -27,11 +27,16 @@ pub mod v3 {
history: {
1.0 => "/_matrix/media/r0/thumbnail/:server_name/:media_id",
1.1 => "/_matrix/media/v3/thumbnail/:server_name/:media_id",
1.11 => deprecated,
}
};
/// Request type for the `get_content_thumbnail` endpoint.
#[request(error = crate::Error)]
#[deprecated = "\
Since Matrix 1.11, clients should use `authenticated_media::get_content_thumbnail::v1::Request` \
instead if the homeserver supports it.\
"]
pub struct Request {
/// The server name from the mxc:// URI (the authoritory component).
#[ruma_api(path)]
@ -90,18 +95,12 @@ pub mod v3 {
/// Whether the server should return an animated thumbnail.
///
/// When `true`, the server should return an animated thumbnail if possible and supported.
/// Otherwise it must not return an animated thumbnail.
///
/// Defaults to `false`.
#[cfg(feature = "unstable-msc2705")]
/// When `Some(true)`, the server should return an animated thumbnail if possible and
/// supported. When `Some(false)`, the server must not return an animated
/// thumbnail. When `None`, the server should not return an animated thumbnail.
#[ruma_api(query)]
#[serde(
rename = "org.matrix.msc2705.animated",
default,
skip_serializing_if = "ruma_common::serde::is_default"
)]
pub animated: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub animated: Option<bool>,
}
/// Response type for the `get_content_thumbnail` endpoint.
@ -141,6 +140,7 @@ pub mod v3 {
pub content_disposition: Option<String>,
}
#[allow(deprecated)]
impl Request {
/// Creates a new `Request` with the given media ID, server name, desired thumbnail width
/// and desired thumbnail height.
@ -159,8 +159,7 @@ pub mod v3 {
allow_remote: true,
timeout_ms: crate::media::default_download_timeout(),
allow_redirect: false,
#[cfg(feature = "unstable-msc2705")]
animated: false,
animated: None,
}
}

View File

@ -20,12 +20,17 @@ pub mod v3 {
history: {
1.0 => "/_matrix/media/r0/config",
1.1 => "/_matrix/media/v3/config",
1.11 => deprecated,
}
};
/// Request type for the `get_media_config` endpoint.
#[request(error = crate::Error)]
#[derive(Default)]
#[deprecated = "\
Since Matrix 1.11, clients should use `authenticated_media::get_media_config::v1::Request` \
instead if the homeserver supports it.\
"]
pub struct Request {}
/// Response type for the `get_media_config` endpoint.
@ -36,6 +41,7 @@ pub mod v3 {
pub upload_size: UInt,
}
#[allow(deprecated)]
impl Request {
/// Creates an empty `Request`.
pub fn new() -> Self {

View File

@ -21,11 +21,16 @@ pub mod v3 {
history: {
1.0 => "/_matrix/media/r0/preview_url",
1.1 => "/_matrix/media/v3/preview_url",
1.11 => deprecated,
}
};
/// Request type for the `get_media_preview` endpoint.
#[request(error = crate::Error)]
#[deprecated = "\
Since Matrix 1.11, clients should use `authenticated_media::get_media_preview::v1::Request` \
instead if the homeserver supports it.\
"]
pub struct Request {
/// URL to get a preview of.
#[ruma_api(query)]
@ -49,6 +54,7 @@ pub mod v3 {
pub data: Option<Box<RawJsonValue>>,
}
#[allow(deprecated)]
impl Request {
/// Creates a new `Request` with the given url.
pub fn new(url: String) -> Self {

View File

@ -9,8 +9,8 @@ pub mod v3 {
//! [by their Matrix identifier][spec-mxid], and one to invite a user
//! [by their third party identifier][spec-3pid].
//!
//! [spec-mxid]: https://spec.matrix.org/v1.10/client-server-api/#post_matrixclientv3roomsroomidinvite
//! [spec-3pid]: https://spec.matrix.org/v1.10/client-server-api/#post_matrixclientv3roomsroomidinvite-1
//! [spec-mxid]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3roomsroomidinvite
//! [spec-3pid]: https://spec.matrix.org/latest/client-server-api/#thirdparty_post_matrixclientv3roomsroomidinvite
use ruma_common::{
api::{request, response, Metadata},

View File

@ -34,7 +34,7 @@ pub mod v3 {
/// One or more custom fields to help identify the third party location.
// The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
#[ruma_api(query_map)]
#[ruma_api(query_all)]
pub fields: BTreeMap<String, String>,
}

View File

@ -34,7 +34,7 @@ pub mod v3 {
/// One or more custom fields that are passed to the AS to help identify the user.
// The specification is incorrect for this parameter. See [matrix-spec#560](https://github.com/matrix-org/matrix-spec/issues/560).
#[ruma_api(query_map)]
#[ruma_api(query_all)]
pub fields: BTreeMap<String, String>,
}

View File

@ -53,6 +53,11 @@ pub enum AuthData {
/// Fallback acknowledgement.
FallbackAcknowledgement(FallbackAcknowledgement),
/// Terms of service (`m.login.terms`).
///
/// This type is only valid during account registration.
Terms(Terms),
#[doc(hidden)]
_Custom(CustomAuthData),
}
@ -90,6 +95,7 @@ impl AuthData {
"m.login.msisdn" => Self::Msisdn(deserialize_variant(session, data)?),
"m.login.dummy" => Self::Dummy(deserialize_variant(session, data)?),
"m.registration_token" => Self::RegistrationToken(deserialize_variant(session, data)?),
"m.login.terms" => Self::Terms(deserialize_variant(session, data)?),
_ => {
Self::_Custom(CustomAuthData { auth_type: auth_type.into(), session, extra: data })
}
@ -111,6 +117,7 @@ impl AuthData {
Self::Dummy(_) => Some(AuthType::Dummy),
Self::RegistrationToken(_) => Some(AuthType::RegistrationToken),
Self::FallbackAcknowledgement(_) => None,
Self::Terms(_) => Some(AuthType::Terms),
Self::_Custom(c) => Some(AuthType::_Custom(PrivOwnedStr(c.auth_type.as_str().into()))),
}
}
@ -125,6 +132,7 @@ impl AuthData {
Self::Dummy(x) => x.session.as_deref(),
Self::RegistrationToken(x) => x.session.as_deref(),
Self::FallbackAcknowledgement(x) => Some(&x.session),
Self::Terms(x) => x.session.as_deref(),
Self::_Custom(x) => x.session.as_deref(),
}
}
@ -165,8 +173,10 @@ impl AuthData {
Self::RegistrationToken(x) => {
Cow::Owned(serialize(RegistrationToken { token: x.token.clone(), session: None }))
}
// Dummy and fallback acknowledgement have no associated data
Self::Dummy(_) | Self::FallbackAcknowledgement(_) => Cow::Owned(JsonObject::default()),
// Dummy, fallback acknowledgement, and terms of service have no associated data
Self::Dummy(_) | Self::FallbackAcknowledgement(_) | Self::Terms(_) => {
Cow::Owned(JsonObject::default())
}
Self::_Custom(c) => Cow::Borrowed(&c.extra),
}
}
@ -183,6 +193,7 @@ impl fmt::Debug for AuthData {
Self::Dummy(inner) => inner.fmt(f),
Self::RegistrationToken(inner) => inner.fmt(f),
Self::FallbackAcknowledgement(inner) => inner.fmt(f),
Self::Terms(inner) => inner.fmt(f),
Self::_Custom(inner) => inner.fmt(f),
}
}
@ -214,6 +225,7 @@ impl<'de> Deserialize<'de> for AuthData {
Some("m.login.registration_token") => {
from_raw_json_value(&json).map(Self::RegistrationToken)
}
Some("m.login.terms") => from_raw_json_value(&json).map(Self::Terms),
None => from_raw_json_value(&json).map(Self::FallbackAcknowledgement),
Some(_) => from_raw_json_value(&json).map(Self::_Custom),
}
@ -253,6 +265,12 @@ pub enum AuthType {
#[ruma_enum(rename = "m.login.registration_token")]
RegistrationToken,
/// Terms of service (`m.login.terms`).
///
/// This type is only valid during account registration.
#[ruma_enum(rename = "m.login.terms")]
Terms,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
@ -434,6 +452,28 @@ impl FallbackAcknowledgement {
}
}
/// Data for terms of service flow.
///
/// This type is only valid during account registration.
///
/// See [the spec] for how to use this.
///
/// [the spec]: https://spec.matrix.org/latest/client-server-api/#terms-of-service-at-registration
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "type", rename = "m.login.terms")]
pub struct Terms {
/// The value of the session key given by the homeserver, if any.
pub session: Option<String>,
}
impl Terms {
/// Creates an empty `Terms`.
pub fn new() -> Self {
Self::default()
}
}
#[doc(hidden)]
#[derive(Clone, Deserialize, Serialize)]
#[non_exhaustive]
@ -551,29 +591,25 @@ pub struct IncomingCustomThirdPartyId {
#[derive(Clone, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct ThirdpartyIdCredentials {
/// Identity server session ID.
/// Identity server (or homeserver) session ID.
pub sid: OwnedSessionId,
/// Identity server client secret.
/// Identity server (or homeserver) client secret.
pub client_secret: OwnedClientSecret,
/// Identity server URL.
pub id_server: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_server: Option<String>,
/// Identity server access token.
pub id_access_token: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub id_access_token: Option<String>,
}
impl ThirdpartyIdCredentials {
/// Creates a new `ThirdpartyIdCredentials` with the given session ID, client secret, identity
/// server address and access token.
pub fn new(
sid: OwnedSessionId,
client_secret: OwnedClientSecret,
id_server: String,
id_access_token: String,
) -> Self {
Self { sid, client_secret, id_server, id_access_token }
/// Creates a new `ThirdpartyIdCredentials` with the given session ID and client secret.
pub fn new(sid: OwnedSessionId, client_secret: OwnedClientSecret) -> Self {
Self { sid, client_secret, id_server: None, id_access_token: None }
}
}

View File

@ -1,9 +1,30 @@
# [unreleased]
Bug fixes:
- The `instance_id` field was removed from `ProtocolInstanceInit` and is now an
`Option<String>` for `ProtocolInstance`. It made the `unstable-unspecified`
feature non-additive.
Breaking changes:
- Rename the `query_map` attribute of the `request` macro to `query_all`, and
remove the required bound to implement `IntoIterator<Item = (String, String)>`.
This allows to use a struct or enum as well as a map to represent the list of
query parameters. Note that the (de)serialization of the type used must work
with `serde_html_form`.
Improvements:
- Add the `InvalidHeaderValue` variant to the `DeserializationError` struct, for
cases where we receive a HTTP header with an unexpected value.
- Implement `Eq`/`Hash`/`PartialEq` for `ThirdPartyIdentifier`, to check whether
a `ThirdPartyIdentifier` has already been added by another user.
- Add `MatrixVersion::V1_11`
- Clarify in the docs of `AuthScheme` that sending an access token via a query
parameter is deprecated, according to MSC4126 / Matrix 1.11.
- Constructing a Matrix URI for an event with a room alias is deprecated,
according to MSC4132 / Matrix 1.11
# 0.13.0

View File

@ -122,9 +122,11 @@ macro_rules! metadata {
/// they are declared must match the order in which they occur in the request path.
/// * `#[ruma_api(query)]`: Fields with this attribute will be inserting into the URL's query
/// string.
/// * `#[ruma_api(query_map)]`: Instead of individual query fields, one query_map field, of any
/// type that implements `IntoIterator<Item = (String, String)>` (e.g. `HashMap<String,
/// String>`, can be used for cases where an endpoint supports arbitrary query parameters.
/// * `#[ruma_api(query_all)]`: Instead of individual query fields, one query_all field, of any
/// type that can be (de)serialized by [serde_html_form], can be used for cases where
/// multiple endpoints should share a query fields type, the query fields are better
/// expressed as an `enum` rather than a `struct`, or the endpoint supports arbitrary query
/// parameters.
/// * No attribute: Fields without an attribute are part of the body. They can use `#[serde]`
/// attributes to customize (de)serialization.
/// * `#[ruma_api(body)]`: Use this if multiple endpoints should share a request body type, or
@ -209,11 +211,13 @@ macro_rules! metadata {
/// # pub struct Response {}
/// }
/// ```
///
/// [serde_html_form]: https://crates.io/crates/serde_html_form
pub use ruma_macros::request;
/// Generates [`OutgoingResponse`] and [`IncomingResponse`] implementations.
///
/// The `OutgoingRequest` impl is feature-gated behind `cfg(feature = "client")`.
/// The `IncomingRequest` impl is feature-gated behind `cfg(feature = "server")`.
/// The `OutgoingResponse` impl is feature-gated behind `cfg(feature = "server")`.
/// The `IncomingResponse` impl is feature-gated behind `cfg(feature = "client")`.
///
/// The generated code expects a `METADATA` constant of type [`Metadata`] to be in scope.
///
@ -223,7 +227,7 @@ pub use ruma_macros::request;
///
/// ## Attributes
///
/// To declare which part of the request a field belongs to:
/// To declare which part of the response a field belongs to:
///
/// * `#[ruma_api(header = HEADER_NAME)]`: Fields with this attribute will be treated as HTTP
/// headers on the response. The value must implement `Display`. Generally this is a
@ -497,19 +501,19 @@ pub enum AuthScheme {
/// Authentication is performed by including an access token in the `Authentication` http
/// header, or an `access_token` query parameter.
///
/// It is recommended to use the header over the query parameter.
/// Using the query parameter is deprecated since Matrix 1.11.
AccessToken,
/// Authentication is optional, and it is performed by including an access token in the
/// `Authentication` http header, or an `access_token` query parameter.
///
/// It is recommended to use the header over the query parameter.
/// Using the query parameter is deprecated since Matrix 1.11.
AccessTokenOptional,
/// Authentication is only performed for appservices, by including an access token in the
/// `Authentication` http header, or an `access_token` query parameter.
///
/// It is recommended to use the header over the query parameter.
/// Using the query parameter is deprecated since Matrix 1.11.
AppserviceToken,
/// Authentication is performed by including X-Matrix signatures in the request headers,

View File

@ -541,6 +541,11 @@ pub enum MatrixVersion {
///
/// See <https://spec.matrix.org/v1.10/>.
V1_10,
/// Version 1.11 of the Matrix specification, released in Q2 2024.
///
/// See <https://spec.matrix.org/v1.11/>.
V1_11,
}
impl TryFrom<&str> for MatrixVersion {
@ -564,6 +569,7 @@ impl TryFrom<&str> for MatrixVersion {
"v1.8" => V1_8,
"v1.9" => V1_9,
"v1.10" => V1_10,
"v1.11" => V1_11,
_ => return Err(UnknownVersionError),
})
}
@ -613,6 +619,7 @@ impl MatrixVersion {
MatrixVersion::V1_8 => (1, 8),
MatrixVersion::V1_9 => (1, 9),
MatrixVersion::V1_10 => (1, 10),
MatrixVersion::V1_11 => (1, 11),
}
}
@ -630,6 +637,7 @@ impl MatrixVersion {
(1, 8) => Ok(MatrixVersion::V1_8),
(1, 9) => Ok(MatrixVersion::V1_9),
(1, 10) => Ok(MatrixVersion::V1_10),
(1, 11) => Ok(MatrixVersion::V1_11),
_ => Err(UnknownVersionError),
}
}
@ -724,7 +732,9 @@ impl MatrixVersion {
// <https://spec.matrix.org/v1.9/rooms/#complete-list-of-room-versions>
| MatrixVersion::V1_9
// <https://spec.matrix.org/v1.10/rooms/#complete-list-of-room-versions>
| MatrixVersion::V1_10 => RoomVersionId::V10,
| MatrixVersion::V1_10
// <https://spec.matrix.org/v1.11/rooms/#complete-list-of-room-versions>
| MatrixVersion::V1_11 => RoomVersionId::V10,
}
}
}

View File

@ -32,6 +32,9 @@ pub enum MatrixId {
User(OwnedUserId),
/// An event ID.
///
/// Constructing this variant from an `OwnedRoomAliasId` is deprecated, because room aliases
/// are mutable, so the URI might break after a while.
Event(OwnedRoomOrAliasId, OwnedEventId),
}
@ -572,12 +575,11 @@ mod tests {
.to_string(),
"https://matrix.to/#/!ruma:notareal.hs?via=notareal.hs"
);
assert_eq!(
room_alias_id!("#ruma:notareal.hs")
.matrix_to_event_uri(event_id!("$event:notareal.hs"))
.to_string(),
"https://matrix.to/#/%23ruma:notareal.hs/$event:notareal.hs"
);
#[allow(deprecated)]
let uri = room_alias_id!("#ruma:notareal.hs")
.matrix_to_event_uri(event_id!("$event:notareal.hs"))
.to_string();
assert_eq!(uri, "https://matrix.to/#/%23ruma:notareal.hs/$event:notareal.hs");
assert_eq!(
room_id!("!ruma:notareal.hs")
.matrix_to_event_uri(event_id!("$event:notareal.hs"))
@ -869,12 +871,11 @@ mod tests {
.to_string(),
"matrix:roomid/ruma:notareal.hs?via=notareal.hs&via=anotherunreal.hs&action=join"
);
assert_eq!(
room_alias_id!("#ruma:notareal.hs")
.matrix_event_uri(event_id!("$event:notareal.hs"))
.to_string(),
"matrix:r/ruma:notareal.hs/e/event:notareal.hs"
);
#[allow(deprecated)]
let uri = room_alias_id!("#ruma:notareal.hs")
.matrix_event_uri(event_id!("$event:notareal.hs"))
.to_string();
assert_eq!(uri, "matrix:r/ruma:notareal.hs/e/event:notareal.hs");
assert_eq!(
room_id!("!ruma:notareal.hs")
.matrix_event_uri(event_id!("$event:notareal.hs"))

View File

@ -37,6 +37,9 @@ impl RoomAliasId {
}
/// Create a `matrix.to` URI for an event scoped under this room alias ID.
///
/// This is deprecated because room aliases are mutable, so the URI might break after a while.
#[deprecated = "Use `RoomId::matrix_to_event_uri` instead."]
pub fn matrix_to_event_uri(&self, ev_id: impl Into<OwnedEventId>) -> MatrixToUri {
MatrixToUri::new((self.to_owned(), ev_id.into()).into(), Vec::new())
}
@ -49,6 +52,9 @@ impl RoomAliasId {
}
/// Create a `matrix:` URI for an event scoped under this room alias ID.
///
/// This is deprecated because room aliases are mutable, so the URI might break after a while.
#[deprecated = "Use `RoomId::matrix_event_uri` instead."]
pub fn matrix_event_uri(&self, ev_id: impl Into<OwnedEventId>) -> MatrixUri {
MatrixUri::new((self.to_owned(), ev_id.into()).into(), Vec::new(), None)
}

View File

@ -602,7 +602,7 @@ mod tests {
assert!(!"m".matches_word("[[:alpha:]]?"));
assert!("[[:alpha:]]!".matches_word("[[:alpha:]]?"));
// From the spec: <https://spec.matrix.org/v1.10/client-server-api/#conditions-1>
// From the spec: <https://spec.matrix.org/v1.11/client-server-api/#conditions-1>
assert!("An example event.".matches_word("ex*ple"));
assert!("exple".matches_word("ex*ple"));
assert!("An exciting triple-whammy".matches_word("ex*ple"));
@ -651,7 +651,7 @@ mod tests {
assert!("".matches_pattern("*", false));
assert!(!"foo".matches_pattern("", false));
// From the spec: <https://spec.matrix.org/v1.10/client-server-api/#conditions-1>
// From the spec: <https://spec.matrix.org/v1.11/client-server-api/#conditions-1>
assert!("Lunch plans".matches_pattern("lunc?*", false));
assert!("LUNCH".matches_pattern("lunc?*", false));
assert!(!" lunch".matches_pattern("lunc?*", false));

View File

@ -2,7 +2,10 @@
//!
//! [thirdparty]: https://spec.matrix.org/latest/client-server-api/#third-party-networks
use std::collections::BTreeMap;
use std::{
collections::BTreeMap,
hash::{Hash, Hasher},
};
use serde::{Deserialize, Serialize};
@ -91,7 +94,8 @@ pub struct ProtocolInstance {
///
/// See [matrix-spec#833](https://github.com/matrix-org/matrix-spec/issues/833).
#[cfg(feature = "unstable-unspecified")]
pub instance_id: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub instance_id: Option<String>,
}
/// Initial set of fields of `Protocol`.
@ -109,30 +113,18 @@ pub struct ProtocolInstanceInit {
/// A unique identifier across all instances.
pub network_id: String,
/// A unique identifier across all instances.
///
/// See [matrix-spec#833](https://github.com/matrix-org/matrix-spec/issues/833).
#[cfg(feature = "unstable-unspecified")]
pub instance_id: String,
}
impl From<ProtocolInstanceInit> for ProtocolInstance {
fn from(init: ProtocolInstanceInit) -> Self {
let ProtocolInstanceInit {
desc,
fields,
network_id,
#[cfg(feature = "unstable-unspecified")]
instance_id,
} = init;
let ProtocolInstanceInit { desc, fields, network_id } = init;
Self {
desc,
icon: None,
fields,
network_id,
#[cfg(feature = "unstable-unspecified")]
instance_id,
instance_id: None,
}
}
}
@ -240,7 +232,6 @@ pub enum Medium {
/// this type using `ThirdPartyIdentifier::Init` / `.into()`.
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ThirdPartyIdentifier {
/// The third party identifier address.
pub address: String,
@ -255,6 +246,20 @@ pub struct ThirdPartyIdentifier {
pub added_at: MilliSecondsSinceUnixEpoch,
}
impl Eq for ThirdPartyIdentifier {}
impl Hash for ThirdPartyIdentifier {
fn hash<H: Hasher>(&self, hasher: &mut H) {
(self.medium.as_str(), &self.address).hash(hasher);
}
}
impl PartialEq for ThirdPartyIdentifier {
fn eq(&self, other: &ThirdPartyIdentifier) -> bool {
self.address == other.address && self.medium == other.medium
}
}
/// Initial set of fields of `ThirdPartyIdentifier`.
///
/// This struct will not be updated even if additional fields are added to `ThirdPartyIdentifier`

View File

@ -133,7 +133,41 @@ pub mod raw_body_endpoint {
}
}
pub mod query_map_endpoint {
pub mod query_all_enum_endpoint {
use ruma_common::{
api::{request, response, Metadata},
metadata,
};
#[derive(Clone, Debug, serde::Deserialize, serde::Serialize)]
#[serde(untagged)]
pub enum MyCustomQueryEnum {
VariantA { field_a: String },
VariantB { field_b: String },
}
const METADATA: Metadata = metadata! {
method: GET,
rate_limited: false,
authentication: None,
history: {
unstable => "/_matrix/some/query/map/endpoint",
}
};
/// Request type for the `query_all_enum_endpoint` endpoint.
#[request]
pub struct Request {
#[ruma_api(query_all)]
pub query: MyCustomQueryEnum,
}
/// Response type for the `query_all_enum_endpoint` endpoint.
#[response]
pub struct Response {}
}
pub mod query_all_vec_endpoint {
use ruma_common::{
api::{request, response, Metadata},
metadata,
@ -148,14 +182,14 @@ pub mod query_map_endpoint {
}
};
/// Request type for the `newtype_body_endpoint` endpoint.
/// Request type for the `query_all_vec_endpoint` endpoint.
#[request]
pub struct Request {
#[ruma_api(query_map)]
#[ruma_api(query_all)]
pub fields: Vec<(String, String)>,
}
/// Response type for the `newtype_body_endpoint` endpoint.
/// Response type for the `query_all_vec_endpoint` endpoint.
#[response]
pub struct Response {}
}

View File

@ -1,12 +1,25 @@
# [unreleased]
Bug fixes:
- Fix deserialization of `AnyGlobalAccountDataEvent` for variants with a type
fragment.
- Fix serialization of `room::message::Relation` and `room::encrypted::Relation`
which could cause duplicate `rel_type` keys.
- `Restricted` no longer fails to deserialize when the `allow` field is missing
Improvements:
- Add support for encrypted stickers as sent by several bridges under the flag `compat-encrypted-stickers`
- Add support for encrypted stickers as sent by several bridges under the flag `compat-encrypted-stickers`
- Add unstable support for MSC3489 `m.beacon` & `m.beacon_info` events
(unstable types `org.matrix.msc3489.beacon` & `org.matrix.msc3489.beacon_info`)
- Stabilize support for muting in VoIP calls, according to Matrix 1.11
- All the root `Any*EventContent` types now have a `EventContentFromType` implementations
automatically derived by the `event_enum!` macro.
Breaking changes:
- `StickerEventContent::url` was replaced by `StickerEventContent::source` which is a `StickerMediaSource`
- `StickerEventContent::url` was replaced by `StickerEventContent::source` which is a `StickerMediaSource`
# 0.28.1

View File

@ -16,7 +16,7 @@ all-features = true
[features]
canonical-json = ["ruma-common/canonical-json"]
html = ["dep:ruma-html"]
markdown = ["pulldown-cmark"]
markdown = ["dep:pulldown-cmark"]
unstable-exhaustive-types = []
unstable-msc1767 = []
unstable-msc2448 = []
@ -29,10 +29,10 @@ unstable-msc3245 = ["unstable-msc3246"]
# https://github.com/matrix-org/matrix-spec-proposals/blob/83f6c5b469c1d78f714e335dcaa25354b255ffa5/proposals/3245-voice-messages.md
unstable-msc3245-v1-compat = []
unstable-msc3246 = ["unstable-msc3927"]
unstable-msc3291 = []
unstable-msc3381 = ["unstable-msc1767"]
unstable-msc3401 = []
unstable-msc3488 = ["unstable-msc1767"]
unstable-msc3489 = ["unstable-msc3488"]
unstable-msc3551 = ["unstable-msc3956"]
unstable-msc3552 = ["unstable-msc3551"]
unstable-msc3553 = ["unstable-msc3552"]
@ -41,7 +41,7 @@ unstable-msc3927 = ["unstable-msc3551"]
unstable-msc3954 = ["unstable-msc1767"]
unstable-msc3955 = ["unstable-msc1767"]
unstable-msc3956 = ["unstable-msc1767"]
unstable-msc4075 = []
unstable-msc4075 = ["unstable-msc3401"]
unstable-pdu = []
# Allow some mandatory fields to be missing, defaulting them to an empty string
@ -65,7 +65,7 @@ indexmap = { version = "2.0.0", features = ["serde"] }
js_int = { workspace = true, features = ["serde"] }
js_option = "0.1.0"
percent-encoding = "2.1.0"
pulldown-cmark = { version = "0.10.3", optional = true, default-features = false, features = ["html"] }
pulldown-cmark = { version = "0.11.0", optional = true, default-features = false, features = ["html"] }
regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] }
ruma-common = { workspace = true }
ruma-html = { workspace = true, optional = true }

View File

@ -0,0 +1,43 @@
//! Types for the `org.matrix.msc3489.beacon` event, the unstable version of
//! `m.beacon` ([MSC3489]).
//!
//! [MSC3489]: https://github.com/matrix-org/matrix-spec-proposals/pull/3489
use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedEventId};
use ruma_events::{location::LocationContent, relation::Reference};
use ruma_macros::EventContent;
use serde::{Deserialize, Serialize};
/// The content of a beacon.
#[derive(Clone, Debug, Serialize, Deserialize, EventContent)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_event(type = "org.matrix.msc3672.beacon", alias = "m.beacon", kind = MessageLike)]
pub struct BeaconEventContent {
/// The beacon_info event id this relates to.
#[serde(rename = "m.relates_to")]
pub relates_to: Reference,
/// The location of the beacon.
#[serde(rename = "org.matrix.msc3488.location")]
pub location: LocationContent,
/// The timestamp of the event.
#[serde(rename = "org.matrix.msc3488.ts")]
pub ts: MilliSecondsSinceUnixEpoch,
}
impl BeaconEventContent {
/// Creates a new `BeaconEventContent` with the given beacon_info event id, geo uri and
/// optional ts. If ts is None, the current time will be used.
pub fn new(
beacon_info_event_id: OwnedEventId,
geo_uri: String,
ts: Option<MilliSecondsSinceUnixEpoch>,
) -> Self {
Self {
relates_to: Reference::new(beacon_info_event_id),
location: LocationContent::new(geo_uri),
ts: ts.unwrap_or_else(MilliSecondsSinceUnixEpoch::now),
}
}
}

View File

@ -0,0 +1,83 @@
//! Types for the `org.matrix.msc3489.beacon_info` state event, the unstable version of
//! `m.beacon_info` ([MSC3489]).
//!
//! [MSC3489]: https://github.com/matrix-org/matrix-spec-proposals/pull/3489
use std::time::{Duration, SystemTime};
use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId};
use ruma_macros::EventContent;
use serde::{Deserialize, Serialize};
use crate::location::AssetContent;
/// The content of a beacon_info state.
#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_event(
type = "org.matrix.msc3672.beacon_info", alias = "m.beacon_info", kind = State, state_key_type = OwnedUserId
)]
pub struct BeaconInfoEventContent {
/// The description of the location.
///
/// It should be used to label the location on a map.
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
/// Whether the user starts sharing their location.
pub live: bool,
/// The time when location sharing started.
#[serde(rename = "org.matrix.msc3488.ts")]
pub ts: MilliSecondsSinceUnixEpoch,
/// The duration that the location sharing will be live.
///
/// Meaning that the location will stop being shared at `ts + timeout`.
#[serde(default, with = "ruma_common::serde::duration::ms")]
pub timeout: Duration,
/// The asset that this message refers to.
#[serde(default, rename = "org.matrix.msc3488.asset")]
pub asset: AssetContent,
}
impl BeaconInfoEventContent {
/// Creates a new `BeaconInfoEventContent` with the given description, live, timeout and
/// optional ts. If ts is None, the current time will be used.
pub fn new(
description: Option<String>,
timeout: Duration,
live: bool,
ts: Option<MilliSecondsSinceUnixEpoch>,
) -> Self {
Self {
description,
live,
ts: ts.unwrap_or_else(MilliSecondsSinceUnixEpoch::now),
timeout,
asset: Default::default(),
}
}
/// Starts the beacon_info being live.
pub fn start(&mut self) {
self.live = true;
}
/// Stops the beacon_info from being live.
pub fn stop(&mut self) {
self.live = false;
}
/// Start time plus its timeout, it returns `false`, indicating that the beacon is not live.
/// Otherwise, it returns `true`.
pub fn is_live(&self) -> bool {
self.live
&& self
.ts
.to_system_time()
.and_then(|t| t.checked_add(self.timeout))
.is_some_and(|t| t > SystemTime::now())
}
}

View File

@ -12,7 +12,6 @@ pub mod negotiate;
#[cfg(feature = "unstable-msc4075")]
pub mod notify;
pub mod reject;
#[cfg(feature = "unstable-msc3291")]
pub mod sdp_stream_metadata_changed;
pub mod select_answer;
@ -59,14 +58,12 @@ pub struct StreamMetadata {
/// Whether the audio track of the stream is muted.
///
/// Defaults to `false`.
#[cfg(feature = "unstable-msc3291")]
#[serde(default, skip_serializing_if = "ruma_common::serde::is_default")]
pub audio_muted: bool,
/// Whether the video track of the stream is muted.
///
/// Defaults to `false`.
#[cfg(feature = "unstable-msc3291")]
#[serde(default, skip_serializing_if = "ruma_common::serde::is_default")]
pub video_muted: bool,
}
@ -74,13 +71,7 @@ pub struct StreamMetadata {
impl StreamMetadata {
/// Creates a new `StreamMetadata` with the given purpose.
pub fn new(purpose: StreamPurpose) -> Self {
Self {
purpose,
#[cfg(feature = "unstable-msc3291")]
audio_muted: false,
#[cfg(feature = "unstable-msc3291")]
video_muted: false,
}
Self { purpose, audio_muted: false, video_muted: false }
}
}

View File

@ -1,65 +1,118 @@
//! Types for matrixRTC state events ([MSC3401]).
//! Types for MatrixRTC state events ([MSC3401]).
//!
//! This implements a newer/updated version of MSC3401.
//!
//! [MSC3401]: https://github.com/matrix-org/matrix-spec-proposals/pull/3401
use std::time::Duration;
mod focus;
mod member_data;
use as_variant::as_variant;
use ruma_common::{serde::StringEnum, MilliSecondsSinceUnixEpoch, OwnedUserId};
use ruma_macros::EventContent;
pub use focus::*;
pub use member_data::*;
use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId};
use ruma_macros::{EventContent, StringEnum};
use serde::{Deserialize, Serialize};
use tracing::warn;
use crate::PrivOwnedStr;
use crate::{
PossiblyRedactedStateEventContent, PrivOwnedStr, RedactContent, RedactedStateEventContent,
StateEventType,
};
/// The member state event for a matrixRTC session.
/// The member state event for a MatrixRTC session.
///
/// This is the object containing all the data related to a matrix users participation in a
/// matrixRTC session. It consists of memberships / sessions.
#[derive(Clone, Debug, Serialize, Deserialize, EventContent)]
#[cfg_attr(test, derive(PartialEq))]
/// This is the object containing all the data related to a Matrix users participation in a
/// MatrixRTC session.
///
/// This is a unit struct with the enum [`CallMemberEventContent`] because a Ruma state event cannot
/// be an enum and we need this to be an untagged enum for parsing purposes. (see
/// [`CallMemberEventContent`])
///
/// This struct also exposes allows to call the methods from [`CallMemberEventContent`].
#[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)]
#[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = OwnedUserId, custom_redacted, custom_possibly_redacted)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = OwnedUserId)]
pub struct CallMemberEventContent {
/// A list of all the memberships that user currently has in this room.
///
/// There can be multiple ones in cases the user participates with multiple devices or there
/// are multiple RTC applications running.
///
/// e.g. a call and a spacial experience.
///
/// Important: This includes expired memberships.
/// To retrieve a list including only valid memberships,
/// see [`active_memberships`](CallMemberEventContent::active_memberships).
pub memberships: Vec<Membership>,
#[serde(untagged)]
pub enum CallMemberEventContent {
/// The legacy format for m.call.member events. (An array of memberships. The devices of one
/// user.)
LegacyContent(LegacyMembershipContent),
/// Normal membership events. One event per membership. Multiple state keys will
/// be used to describe multiple devices for one user.
SessionContent(SessionMembershipData),
/// An empty content means this user has been in a rtc session but is not anymore.
Empty(EmptyMembershipData),
}
impl CallMemberEventContent {
/// Creates a new `CallMemberEventContent`.
pub fn new(memberships: Vec<Membership>) -> Self {
Self { memberships }
/// Creates a new [`CallMemberEventContent`] with [`LegacyMembershipData`].
pub fn new_legacy(memberships: Vec<LegacyMembershipData>) -> Self {
Self::LegacyContent(LegacyMembershipContent {
memberships, //: memberships.into_iter().map(MembershipData::Legacy).collect(),
})
}
/// Creates a new [`CallMemberEventContent`] with [`SessionMembershipData`].
pub fn new(
application: Application,
device_id: String,
focus_active: ActiveFocus,
foci_preferred: Vec<Focus>,
created_ts: Option<MilliSecondsSinceUnixEpoch>,
) -> Self {
Self::SessionContent(SessionMembershipData {
application,
device_id,
focus_active,
foci_preferred,
created_ts,
})
}
/// Creates a new Empty [`CallMemberEventContent`] representing a left membership.
pub fn new_empty(leave_reason: Option<LeaveReason>) -> Self {
Self::Empty(EmptyMembershipData { leave_reason })
}
/// All non expired memberships in this member event.
///
/// In most cases you want tu use this method instead of the public memberships field.
/// In most cases you want to use this method instead of the public memberships field.
/// The memberships field will also include expired events.
///
/// This copies all the memberships and converts them
/// # Arguments
///
/// * `origin_server_ts` - optionally the `origin_server_ts` can be passed as a fallback in case
/// the Membership does not contain `created_ts`. (`origin_server_ts` will be ignored if
/// `created_ts` is `Some`)
/// * `origin_server_ts` - optionally the `origin_server_ts` can be passed as a fallback in the
/// Membership does not contain [`LegacyMembershipData::created_ts`]. (`origin_server_ts` will
/// be ignored if [`LegacyMembershipData::created_ts`] is `Some`)
pub fn active_memberships(
&self,
origin_server_ts: Option<MilliSecondsSinceUnixEpoch>,
) -> Vec<&Membership> {
self.memberships.iter().filter(|m| !m.is_expired(origin_server_ts)).collect()
) -> Vec<MembershipData<'_>> {
match self {
CallMemberEventContent::LegacyContent(content) => {
content.active_memberships(origin_server_ts)
}
CallMemberEventContent::SessionContent(content) => {
[content].map(MembershipData::Session).to_vec()
}
CallMemberEventContent::Empty(_) => Vec::new(),
}
}
/// Set the `created_ts` of each [Membership] in this event.
/// All the memberships for this event. Can only contain multiple elements in the case of legacy
/// `m.call.member` state events.
pub fn memberships(&self) -> Vec<MembershipData<'_>> {
match self {
CallMemberEventContent::LegacyContent(content) => {
content.memberships.iter().map(MembershipData::Legacy).collect()
}
CallMemberEventContent::SessionContent(content) => {
[content].map(MembershipData::Session).to_vec()
}
CallMemberEventContent::Empty(_) => Vec::new(),
}
}
/// Set the `created_ts` of each [`MembershipData::Legacy`] in this event.
///
/// Each call member event contains the `origin_server_ts` and `content.create_ts`.
/// `content.create_ts` is undefined for the initial event of a session (because the
@ -68,257 +121,130 @@ impl CallMemberEventContent {
/// (This allows to use `MinimalStateEvents` and still be able to determine if a membership is
/// expired)
pub fn set_created_ts_if_none(&mut self, origin_server_ts: MilliSecondsSinceUnixEpoch) {
self.memberships.iter_mut().for_each(|m| {
m.created_ts.get_or_insert(origin_server_ts);
});
}
}
/// A membership describes one of the sessions this user currently partakes.
///
/// The application defines the type of the session.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Membership {
/// The type of the matrixRTC session the membership belongs to.
///
/// e.g. call, spacial, document...
#[serde(flatten)]
pub application: Application,
/// The device id of this membership.
///
/// The same user can join with their phone/computer.
pub device_id: String,
/// The duration in milliseconds relative to the time this membership joined
/// during which the membership is valid.
///
/// The time a member has joined is defined as:
/// `MIN(content.created_ts, event.origin_server_ts)`
#[serde(with = "ruma_common::serde::duration::ms")]
pub expires: Duration,
/// Stores a copy of the `origin_server_ts` of the initial session event.
///
/// If the membership is updated this field will be used to track to
/// original `origin_server_ts`.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_ts: Option<MilliSecondsSinceUnixEpoch>,
/// A list of the foci in use for this membership.
pub foci_active: Vec<Focus>,
/// The id of the membership.
///
/// This is required to guarantee uniqueness of the event.
/// Sending the same state event twice to synapse makes the HS drop the second one and return
/// 200.
#[serde(rename = "membershipID")]
pub membership_id: String,
}
impl Membership {
/// The application of the membership is "m.call" and the scope is "m.room".
pub fn is_room_call(&self) -> bool {
as_variant!(&self.application, Application::Call)
.is_some_and(|call| call.scope == CallScope::Room)
}
/// The application of the membership is "m.call".
pub fn is_call(&self) -> bool {
as_variant!(&self.application, Application::Call).is_some()
}
/// Checks if the event is expired.
///
/// Defaults to using `created_ts` of the `Membership`.
/// If no `origin_server_ts` is provided and the event does not contain `created_ts`
/// the event will be considered as not expired.
/// In this case, a warning will be logged.
///
/// # Arguments
///
/// * `origin_server_ts` - a fallback if `created_ts` is not present
pub fn is_expired(&self, origin_server_ts: Option<MilliSecondsSinceUnixEpoch>) -> bool {
let ev_created_ts = self.created_ts.or(origin_server_ts);
if let Some(ev_created_ts) = ev_created_ts {
let now = MilliSecondsSinceUnixEpoch::now().to_system_time();
let expire_ts = ev_created_ts.to_system_time().map(|t| t + self.expires);
now > expire_ts
} else {
// This should not be reached since we only allow events that have copied over
// the origin server ts. `set_created_ts_if_none`
warn!("Encountered a Call Member state event where the origin_ts (or origin_server_ts) could not be found.\
It is treated as a non expired event but this might be wrong.");
false
match self {
CallMemberEventContent::LegacyContent(content) => {
content.memberships.iter_mut().for_each(|m: &mut LegacyMembershipData| {
m.created_ts.get_or_insert(origin_server_ts);
});
}
CallMemberEventContent::SessionContent(m) => {
m.created_ts.get_or_insert(origin_server_ts);
}
_ => (),
}
}
}
/// Initial set of fields of [`Membership`].
#[derive(Debug)]
#[allow(clippy::exhaustive_structs)]
pub struct MembershipInit {
/// The type of the matrixRTC session the membership belongs to.
///
/// e.g. call, spacial, document...
pub application: Application,
/// The device id of this membership.
///
/// The same user can join with their phone/computer.
pub device_id: String,
/// The duration in milliseconds relative to the time this membership joined
/// during which the membership is valid.
///
/// The time a member has joined is defined as:
/// `MIN(content.created_ts, event.origin_server_ts)`
pub expires: Duration,
/// A list of the focuses (foci) in use for this membership.
pub foci_active: Vec<Focus>,
/// The id of the membership.
///
/// This is required to guarantee uniqueness of the event.
/// Sending the same state event twice to synapse makes the HS drop the second one and return
/// 200.
pub membership_id: String,
}
impl From<MembershipInit> for Membership {
fn from(init: MembershipInit) -> Self {
let MembershipInit { application, device_id, expires, foci_active, membership_id } = init;
Self { application, device_id, expires, created_ts: None, foci_active, membership_id }
}
}
/// Description of the SFU/Focus a membership can be connected to.
///
/// A focus can be any server powering the matrixRTC session (SFU,
/// MCU). It serves as a node to redistribute RTC streams.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
/// This describes the CallMember event if the user is not part of the current session.
#[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Focus {
/// Livekit is one possible type of SFU/Focus that can be used for a matrixRTC session.
Livekit(LivekitFocus),
pub struct EmptyMembershipData {
/// An empty call member state event can optionally contain a leave reason.
/// If it is `None` the user has left the call ordinarily. (Intentional hangup)
#[serde(skip_serializing_if = "Option::is_none")]
pub leave_reason: Option<LeaveReason>,
}
/// The fields to describe livekit as an `active_foci`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct LivekitFocus {
/// The alias where the livekit sessions can be reached.
#[serde(rename = "livekit_alias")]
pub alias: String,
/// The url of the jwt server for the livekit instance.
#[serde(rename = "livekit_service_url")]
pub service_url: String,
}
impl LivekitFocus {
/// Initialize a [`LivekitFocus`].
///
/// # Arguments
///
/// * `alias` - The alias where the livekit sessions can be reached.
/// * `service_url` - The url of the jwt server for the livekit instance.
pub fn new(alias: String, service_url: String) -> Self {
Self { alias, service_url }
}
}
/// The type of the matrixRTC session.
///
/// This is not the application/client used by the user but the
/// type of matrixRTC session e.g. calling (`m.call`), third-room, whiteboard could be
/// possible applications.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "application")]
pub enum Application {
#[serde(rename = "m.call")]
/// A VoIP call.
Call(CallApplicationContent),
}
/// Call specific parameters membership parameters.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct CallApplicationContent {
/// An identifier for calls.
///
/// All members using the same `call_id` will end up in the same call.
///
/// Does not need to be a uuid.
///
/// `""` is used for room scoped calls.
pub call_id: String,
/// Who owns/joins/controls (can modify) the call.
pub scope: CallScope,
}
impl CallApplicationContent {
/// Initialize a [`CallApplicationContent`].
///
/// # Arguments
///
/// * `call_id` - An identifier for calls. All members using the same `call_id` will end up in
/// the same call. Does not need to be a uuid. `""` is used for room scoped calls.
/// * `scope` - Who owns/joins/controls (can modify) the call.
pub fn new(call_id: String, scope: CallScope) -> Self {
Self { call_id, scope }
}
}
/// The call scope defines different call ownership models.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
/// This is the optional value for an empty membership event content:
/// [`CallMemberEventContent::Empty`]. It is used when the user disconnected and a Future ([MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140))
/// was used to update the membership after the client was not reachable anymore.
#[derive(Clone, PartialEq, StringEnum)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_enum(rename_all = "m.snake_case")]
pub enum CallScope {
/// A call which every user of a room can join and create.
///
/// There is no particular name associated with it.
///
/// There can only be one per room.
Room,
/// A user call is owned by a user.
///
/// Each user can create one there can be multiple per room. They are started and ended by the
/// owning user.
User,
pub enum LeaveReason {
/// The user left the call by losing network connection or closing
/// the client before it was able to send the leave event.
LostConnection,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
impl RedactContent for CallMemberEventContent {
type Redacted = RedactedCallMemberEventContent;
fn redact(self, _version: &ruma_common::RoomVersionId) -> Self::Redacted {
RedactedCallMemberEventContent {}
}
}
/// The PossiblyRedacted version of [`CallMemberEventContent`].
///
/// Since [`CallMemberEventContent`] has the [`CallMemberEventContent::Empty`] state it already is
/// compatible with the redacted version of the state event content.
pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent;
impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent {
type StateKey = OwnedUserId;
}
/// The Redacted version of [`CallMemberEventContent`].
#[derive(Clone, Debug, Deserialize, Serialize)]
#[allow(clippy::exhaustive_structs)]
pub struct RedactedCallMemberEventContent {}
impl ruma_events::content::EventContent for RedactedCallMemberEventContent {
type EventType = StateEventType;
fn event_type(&self) -> Self::EventType {
StateEventType::CallMember
}
}
impl RedactedStateEventContent for RedactedCallMemberEventContent {
type StateKey = OwnedUserId;
}
/// Legacy content with an array of memberships. See also: [`CallMemberEventContent`]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct LegacyMembershipContent {
/// A list of all the memberships that user currently has in this room.
///
/// There can be multiple ones in case the user participates with multiple devices or there
/// are multiple RTC applications running.
///
/// e.g. a call and a spacial experience.
///
/// Important: This includes expired memberships.
/// To retrieve a list including only valid memberships,
/// see [`active_memberships`](CallMemberEventContent::active_memberships).
memberships: Vec<LegacyMembershipData>,
}
impl LegacyMembershipContent {
fn active_memberships(
&self,
origin_server_ts: Option<MilliSecondsSinceUnixEpoch>,
) -> Vec<MembershipData<'_>> {
self.memberships
.iter()
.filter(|m| !m.is_expired(origin_server_ts))
.map(MembershipData::Legacy)
.collect()
}
}
#[cfg(test)]
mod tests {
use std::time::Duration;
use ruma_common::MilliSecondsSinceUnixEpoch as TS;
use serde_json::json;
use assert_matches2::assert_matches;
use ruma_common::{MilliSecondsSinceUnixEpoch as TS, OwnedEventId, OwnedRoomId, OwnedUserId};
use serde_json::{from_value as from_json_value, json};
use super::{
Application, CallApplicationContent, CallMemberEventContent, CallScope, Focus,
LivekitFocus, Membership,
focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus},
member_data::{
Application, CallApplicationContent, CallScope, LegacyMembershipData, MembershipData,
},
CallMemberEventContent,
};
use crate::{
call::member::{EmptyMembershipData, FocusSelection, SessionMembershipData},
AnyStateEvent, StateEvent,
};
fn create_call_member_event_content() -> CallMemberEventContent {
CallMemberEventContent::new(vec![Membership {
fn create_call_member_legacy_event_content() -> CallMemberEventContent {
CallMemberEventContent::new_legacy(vec![LegacyMembershipData {
application: Application::Call(CallApplicationContent {
call_id: "123456".to_owned(),
scope: CallScope::Room,
@ -334,8 +260,60 @@ mod tests {
}])
}
fn create_call_member_event_content() -> CallMemberEventContent {
CallMemberEventContent::new(
Application::Call(CallApplicationContent {
call_id: "123456".to_owned(),
scope: CallScope::Room,
}),
"ABCDE".to_owned(),
ActiveFocus::Livekit(ActiveLivekitFocus {
focus_select: FocusSelection::OldestMembership,
}),
vec![Focus::Livekit(LivekitFocus {
alias: "1".to_owned(),
service_url: "https://livekit.com".to_owned(),
})],
None,
)
}
#[test]
fn serialize_call_member_event_content() {
let call_member_event = &json!({
"application": "m.call",
"call_id": "123456",
"scope": "m.room",
"device_id": "ABCDE",
"foci_preferred": [
{
"livekit_alias": "1",
"livekit_service_url": "https://livekit.com",
"type": "livekit"
}
],
"focus_active":{
"type":"livekit",
"focus_select":"oldest_membership"
}
});
assert_eq!(
call_member_event,
&serde_json::to_value(create_call_member_event_content()).unwrap()
);
let empty_call_member_event = &json!({});
assert_eq!(
empty_call_member_event,
&serde_json::to_value(CallMemberEventContent::Empty(EmptyMembershipData {
leave_reason: None
}))
.unwrap()
);
}
#[test]
fn serialize_legacy_call_member_event_content() {
let call_member_event = &json!({
"memberships": [
{
@ -358,14 +336,62 @@ mod tests {
assert_eq!(
call_member_event,
&serde_json::to_value(create_call_member_event_content()).unwrap()
&serde_json::to_value(create_call_member_legacy_event_content()).unwrap()
);
}
#[test]
fn deserialize_call_member_event_content() {
let call_member_ev = CallMemberEventContent::new(
Application::Call(CallApplicationContent {
call_id: "123456".to_owned(),
scope: CallScope::Room,
}),
"THIS_DEVICE".to_owned(),
ActiveFocus::Livekit(ActiveLivekitFocus {
focus_select: FocusSelection::OldestMembership,
}),
vec![Focus::Livekit(LivekitFocus {
alias: "room1".to_owned(),
service_url: "https://livekit1.com".to_owned(),
})],
None,
);
let call_member_ev_json = json!({
"application": "m.call",
"call_id": "123456",
"scope": "m.room",
"device_id": "THIS_DEVICE",
"focus_active":{
"type": "livekit",
"focus_select": "oldest_membership"
},
"foci_preferred": [
{
"livekit_alias": "room1",
"livekit_service_url": "https://livekit1.com",
"type": "livekit"
}
],
});
let ev_content: CallMemberEventContent =
serde_json::from_value(call_member_ev_json).unwrap();
assert_eq!(
serde_json::to_string(&ev_content).unwrap(),
serde_json::to_string(&call_member_ev).unwrap()
);
let empty = CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None });
assert_eq!(
serde_json::to_string(&json!({})).unwrap(),
serde_json::to_string(&empty).unwrap()
);
}
#[test]
fn deserialize_call_member_event_content() {
let call_member_ev: CallMemberEventContent = CallMemberEventContent::new(vec![
Membership {
fn deserialize_legacy_call_member_event_content() {
let call_member_ev = CallMemberEventContent::new_legacy(vec![
LegacyMembershipData {
application: Application::Call(CallApplicationContent {
call_id: "123456".to_owned(),
scope: CallScope::Room,
@ -379,7 +405,7 @@ mod tests {
membership_id: "0".to_owned(),
created_ts: None,
},
Membership {
LegacyMembershipData {
application: Application::Call(CallApplicationContent {
call_id: "".to_owned(),
scope: CallScope::Room,
@ -432,7 +458,85 @@ mod tests {
let ev_content: CallMemberEventContent =
serde_json::from_value(call_member_ev_json).unwrap();
assert_eq!(ev_content, call_member_ev);
assert_eq!(
serde_json::to_string(&ev_content).unwrap(),
serde_json::to_string(&call_member_ev).unwrap()
);
}
#[test]
fn deserialize_member_event() {
let ev = json!({
"content":{
"application": "m.call",
"call_id": "",
"scope": "m.room",
"device_id": "THIS_DEVICE",
"focus_active":{
"type": "livekit",
"focus_select": "oldest_membership"
},
"foci_preferred": [
{
"livekit_alias": "room1",
"livekit_service_url": "https://livekit1.com",
"type": "livekit"
}
],
},
"type": "m.call.member",
"origin_server_ts": 111,
"event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc",
"room_id": "!1234:example.org",
"sender": "@user:example.org",
"state_key":"@user:example.org",
"unsigned":{
"age":10,
"prev_content": {},
"prev_sender":"@user:example.org",
}
});
assert_matches!(
from_json_value(ev),
Ok(AnyStateEvent::CallMember(StateEvent::Original(member_event)))
);
let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap();
let sender = OwnedUserId::try_from("@user:example.org").unwrap();
let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap();
assert_eq!(member_event.state_key, sender);
assert_eq!(member_event.event_id, event_id);
assert_eq!(member_event.sender, sender);
assert_eq!(member_event.room_id, room_id);
assert_eq!(member_event.origin_server_ts, TS(js_int::UInt::new(111).unwrap()));
assert_eq!(
member_event.content,
CallMemberEventContent::SessionContent(SessionMembershipData {
application: Application::Call(CallApplicationContent {
call_id: "".to_owned(),
scope: CallScope::Room
}),
device_id: "THIS_DEVICE".to_owned(),
foci_preferred: [Focus::Livekit(LivekitFocus {
alias: "room1".to_owned(),
service_url: "https://livekit1.com".to_owned()
})]
.to_vec(),
focus_active: ActiveFocus::Livekit(ActiveLivekitFocus {
focus_select: FocusSelection::OldestMembership
}),
created_ts: None
})
);
assert_eq!(js_int::Int::new(10), member_event.unsigned.age);
assert_eq!(
CallMemberEventContent::Empty(EmptyMembershipData { leave_reason: None }),
member_event.unsigned.prev_content.unwrap()
);
// assert_eq!(, StateUnsigned { age: 10, transaction_id: None, prev_content:
// CallMemberEventContent::Empty { leave_reason: None }, relations: None })
}
fn timestamps() -> (TS, TS, TS) {
@ -449,44 +553,59 @@ mod tests {
}
#[test]
fn membership_do_expire() {
let content = create_call_member_event_content();
fn memberships_do_expire() {
let content_legacy = create_call_member_legacy_event_content();
let (now, one_second_ago, two_hours_ago) = timestamps();
assert_eq!(
content.active_memberships(Some(one_second_ago)),
content.memberships.iter().collect::<Vec<&Membership>>()
content_legacy.active_memberships(Some(one_second_ago)),
content_legacy.memberships()
);
assert_eq!(content_legacy.active_memberships(Some(now)), content_legacy.memberships());
assert_eq!(
content.active_memberships(Some(now)),
content.memberships.iter().collect::<Vec<&Membership>>()
content_legacy.active_memberships(Some(two_hours_ago)),
(vec![] as Vec<MembershipData<'_>>)
);
// session do never expire
let content_session = create_call_member_event_content();
assert_eq!(
content_session.active_memberships(Some(one_second_ago)),
content_session.memberships()
);
assert_eq!(content_session.active_memberships(Some(now)), content_session.memberships());
assert_eq!(
content_session.active_memberships(Some(two_hours_ago)),
content_session.memberships()
);
assert_eq!(content.active_memberships(Some(two_hours_ago)), vec![] as Vec<&Membership>);
}
#[test]
fn set_created_ts() {
let mut content_now = create_call_member_event_content();
let mut content_two_hours_ago = create_call_member_event_content();
let mut content_one_second_ago = create_call_member_event_content();
let mut content_now = create_call_member_legacy_event_content();
let mut content_two_hours_ago = create_call_member_legacy_event_content();
let mut content_one_second_ago = create_call_member_legacy_event_content();
let (now, one_second_ago, two_hours_ago) = timestamps();
content_now.set_created_ts_if_none(now);
content_one_second_ago.set_created_ts_if_none(one_second_ago);
content_two_hours_ago.set_created_ts_if_none(two_hours_ago);
assert_eq!(
content_now.active_memberships(None),
content_now.memberships.iter().collect::<Vec<&Membership>>()
);
assert_eq!(content_now.active_memberships(None), content_now.memberships());
assert_eq!(content_two_hours_ago.active_memberships(None), vec![] as Vec<&Membership>);
assert_eq!(
content_two_hours_ago.active_memberships(None),
vec![] as Vec<MembershipData<'_>>
);
assert_eq!(
content_one_second_ago.active_memberships(None),
content_one_second_ago.memberships.iter().collect::<Vec<&Membership>>()
content_one_second_ago.memberships()
);
// created_ts should not be overwritten.
content_two_hours_ago.set_created_ts_if_none(one_second_ago);
// There still should be no active membership.
assert_eq!(content_two_hours_ago.active_memberships(None), vec![] as Vec<&Membership>);
assert_eq!(
content_two_hours_ago.active_memberships(None),
vec![] as Vec<MembershipData<'_>>
);
}
}

View File

@ -0,0 +1,88 @@
//! Types for MatrixRTC Focus/SFU configurations.
use ruma_macros::StringEnum;
use serde::{Deserialize, Serialize};
use crate::PrivOwnedStr;
/// Description of the SFU/Focus a membership can be connected to.
///
/// A focus can be any server powering the MatrixRTC session (SFU,
/// MCU). It serves as a node to redistribute RTC streams.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Focus {
/// LiveKit is one possible type of SFU/Focus that can be used for a MatrixRTC session.
Livekit(LivekitFocus),
}
/// The struct to describe LiveKit as a `preferred_foci`.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct LivekitFocus {
/// The alias where the LiveKit sessions can be reached.
#[serde(rename = "livekit_alias")]
pub alias: String,
/// The URL of the JWT service for the LiveKit instance.
#[serde(rename = "livekit_service_url")]
pub service_url: String,
}
impl LivekitFocus {
/// Initialize a [`LivekitFocus`].
///
/// # Arguments
///
/// * `alias` - The alias with which the LiveKit sessions can be reached.
/// * `service_url` - The url of the JWT server for the LiveKit instance.
pub fn new(alias: String, service_url: String) -> Self {
Self { alias, service_url }
}
}
/// Data to define the actively used Focus.
///
/// A focus can be any server powering the MatrixRTC session (SFU,
/// MCU). It serves as a node to redistribute RTC streams.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ActiveFocus {
/// LiveKit is one possible type of SFU/Focus that can be used for a MatrixRTC session.
Livekit(ActiveLivekitFocus),
}
/// The fields to describe the `active_foci`.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct ActiveLivekitFocus {
/// The selection method used to select the LiveKit focus for the rtc session.
pub focus_select: FocusSelection,
}
impl ActiveLivekitFocus {
/// Initialize a [`ActiveLivekitFocus`].
///
/// # Arguments
///
/// * `focus_select` - The selection method used to select the LiveKit focus for the rtc
/// session.
pub fn new() -> Self {
Self { focus_select: FocusSelection::OldestMembership }
}
}
/// How to select the active focus for LiveKit
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, PartialEq, StringEnum)]
#[ruma_enum(rename_all = "snake_case")]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum FocusSelection {
/// Select the active focus by using the oldest membership and the oldest focus.
OldestMembership,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}

View File

@ -0,0 +1,319 @@
//! Types for MatrixRTC `m.call.member` state event content data ([MSC3401])
//!
//! [MSC3401]: https://github.com/matrix-org/matrix-spec-proposals/pull/3401
use std::time::Duration;
use as_variant::as_variant;
use ruma_common::MilliSecondsSinceUnixEpoch;
use ruma_macros::StringEnum;
use serde::{Deserialize, Serialize};
use tracing::warn;
use super::focus::{ActiveFocus, ActiveLivekitFocus, Focus};
use crate::PrivOwnedStr;
/// The data object that contains the information for one membership.
///
/// It can be a legacy or a normal MatrixRTC Session membership.
///
/// The legacy format contains time information to compute if it is expired or not.
/// SessionMembershipData does not have the concept of timestamp based expiration anymore.
/// The state event will reliably be set to empty when the user disconnects.
#[derive(Clone, Debug)]
#[cfg_attr(test, derive(PartialEq))]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum MembershipData<'a> {
/// The legacy format (using an array of memberships for each device -> one event per user)
Legacy(&'a LegacyMembershipData),
/// One event per device. `SessionMembershipData` contains all the information required to
/// represent the current membership state of one device.
Session(&'a SessionMembershipData),
}
impl<'a> MembershipData<'a> {
/// The application this RTC membership participates in (the session type, can be `m.call`...)
pub fn application(&self) -> &Application {
match self {
MembershipData::Legacy(data) => &data.application,
MembershipData::Session(data) => &data.application,
}
}
/// The device id of this membership.
pub fn device_id(&self) -> &String {
match self {
MembershipData::Legacy(data) => &data.device_id,
MembershipData::Session(data) => &data.device_id,
}
}
/// The active focus is a FocusType specific object that describes how this user
/// is currently connected.
///
/// It can use the foci_preferred list to choose one of the available (preferred)
/// foci or specific information on how to connect to this user.
///
/// Every user needs to converge to use the same focus_active type.
pub fn focus_active(&self) -> &ActiveFocus {
match self {
MembershipData::Legacy(_) => &ActiveFocus::Livekit(ActiveLivekitFocus {
focus_select: super::focus::FocusSelection::OldestMembership,
}),
MembershipData::Session(data) => &data.focus_active,
}
}
/// The list of available/preferred options this user provides to connect to the call.
pub fn foci_preferred(&self) -> &Vec<Focus> {
match self {
MembershipData::Legacy(data) => &data.foci_active,
MembershipData::Session(data) => &data.foci_preferred,
}
}
/// The application of the membership is "m.call" and the scope is "m.room".
pub fn is_room_call(&self) -> bool {
as_variant!(self.application(), Application::Call)
.is_some_and(|call| call.scope == CallScope::Room)
}
/// The application of the membership is "m.call".
pub fn is_call(&self) -> bool {
as_variant!(self.application(), Application::Call).is_some()
}
/// Checks if the event is expired. This is only relevant for LegacyMembershipData
/// returns `false` if its SessionMembershipData
pub fn is_expired(&self, origin_server_ts: Option<MilliSecondsSinceUnixEpoch>) -> bool {
match self {
MembershipData::Legacy(data) => data.is_expired(origin_server_ts),
MembershipData::Session(_) => false,
}
}
/// Gets the created_ts of the event.
///
/// This is the `origin_server_ts` for session data.
/// For legacy events this can either be the origin server ts or a copy from the
/// `origin_server_ts` since we expect legacy events to get updated (when a new device
/// joins/leaves).
pub fn created_ts(&self) -> Option<MilliSecondsSinceUnixEpoch> {
match self {
MembershipData::Legacy(data) => data.created_ts,
MembershipData::Session(data) => data.created_ts,
}
}
}
/// A membership describes one of the sessions this user currently partakes.
///
/// The application defines the type of the session.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct LegacyMembershipData {
/// The type of the MatrixRTC session the membership belongs to.
///
/// e.g. call, spacial, document...
#[serde(flatten)]
pub application: Application,
/// The device id of this membership.
///
/// The same user can join with their phone/computer.
pub device_id: String,
/// The duration in milliseconds relative to the time this membership joined
/// during which the membership is valid.
///
/// The time a member has joined is defined as:
/// `MIN(content.created_ts, event.origin_server_ts)`
#[serde(with = "ruma_common::serde::duration::ms")]
pub expires: Duration,
/// Stores a copy of the `origin_server_ts` of the initial session event.
///
/// If the membership is updated this field will be used to track to
/// original `origin_server_ts`.
#[serde(skip_serializing_if = "Option::is_none")]
pub created_ts: Option<MilliSecondsSinceUnixEpoch>,
/// A list of the foci in use for this membership.
pub foci_active: Vec<Focus>,
/// The id of the membership.
///
/// This is required to guarantee uniqueness of the event.
/// Sending the same state event twice to synapse makes the HS drop the second one and return
/// 200.
#[serde(rename = "membershipID")]
pub membership_id: String,
}
impl LegacyMembershipData {
/// Checks if the event is expired.
///
/// Defaults to using `created_ts` of the [`LegacyMembershipData`].
/// If no `origin_server_ts` is provided and the event does not contain `created_ts`
/// the event will be considered as not expired.
/// In this case, a warning will be logged.
///
/// # Arguments
///
/// * `origin_server_ts` - a fallback if [`LegacyMembershipData::created_ts`] is not present
pub fn is_expired(&self, origin_server_ts: Option<MilliSecondsSinceUnixEpoch>) -> bool {
let ev_created_ts = self.created_ts.or(origin_server_ts);
if let Some(ev_created_ts) = ev_created_ts {
let now = MilliSecondsSinceUnixEpoch::now().to_system_time();
let expire_ts = ev_created_ts.to_system_time().map(|t| t + self.expires);
now > expire_ts
} else {
// This should not be reached since we only allow events that have copied over
// the origin server ts. `set_created_ts_if_none`
warn!("Encountered a Call Member state event where the origin_ts (or origin_server_ts) could not be found.\
It is treated as a non expired event but this might be wrong.");
false
}
}
}
/// Initial set of fields of [`LegacyMembershipData`].
#[derive(Debug)]
#[allow(clippy::exhaustive_structs)]
pub struct LegacyMembershipDataInit {
/// The type of the MatrixRTC session the membership belongs to.
///
/// e.g. call, spacial, document...
pub application: Application,
/// The device id of this membership.
///
/// The same user can join with their phone/computer.
pub device_id: String,
/// The duration in milliseconds relative to the time this membership joined
/// during which the membership is valid.
///
/// The time a member has joined is defined as:
/// `MIN(content.created_ts, event.origin_server_ts)`
pub expires: Duration,
/// A list of the focuses (foci) in use for this membership.
pub foci_active: Vec<Focus>,
/// The id of the membership.
///
/// This is required to guarantee uniqueness of the event.
/// Sending the same state event twice to synapse makes the HS drop the second one and return
/// 200.
pub membership_id: String,
}
impl From<LegacyMembershipDataInit> for LegacyMembershipData {
fn from(init: LegacyMembershipDataInit) -> Self {
let LegacyMembershipDataInit {
application,
device_id,
expires,
foci_active,
membership_id,
} = init;
Self { application, device_id, expires, created_ts: None, foci_active, membership_id }
}
}
/// Stores all the information for a MatrixRTC membership. (one for each device)
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct SessionMembershipData {
/// The type of the MatrixRTC session the membership belongs to.
///
/// e.g. call, spacial, document...
#[serde(flatten)]
pub application: Application,
/// The device id of this membership.
///
/// The same user can join with their phone/computer.
pub device_id: String,
/// A list of the foci that this membership proposes to use.
pub foci_preferred: Vec<Focus>,
/// Data required to determine the currently used focus by this member.
pub focus_active: ActiveFocus,
/// Stores a copy of the `origin_server_ts` of the initial session event.
///
/// This is not part of the serialized event and computed after serialization.
#[serde(skip)]
pub created_ts: Option<MilliSecondsSinceUnixEpoch>,
}
/// The type of the MatrixRTC session.
///
/// This is not the application/client used by the user but the
/// type of MatrixRTC session e.g. calling (`m.call`), third-room, whiteboard could be
/// possible applications.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "application")]
pub enum Application {
/// The rtc application (session type) for VoIP call.
#[serde(rename = "m.call")]
Call(CallApplicationContent),
}
/// Call specific parameters of a `m.call.member` event.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct CallApplicationContent {
/// An identifier for calls.
///
/// All members using the same `call_id` will end up in the same call.
///
/// Does not need to be a uuid.
///
/// `""` is used for room scoped calls.
pub call_id: String,
/// Who owns/joins/controls (can modify) the call.
pub scope: CallScope,
}
impl CallApplicationContent {
/// Initialize a [`CallApplicationContent`].
///
/// # Arguments
///
/// * `call_id` - An identifier for calls. All members using the same `call_id` will end up in
/// the same call. Does not need to be a uuid. `""` is used for room scoped calls.
/// * `scope` - Who owns/joins/controls (can modify) the call.
pub fn new(call_id: String, scope: CallScope) -> Self {
Self { call_id, scope }
}
}
/// The call scope defines different call ownership models.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, PartialEq, StringEnum)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_enum(rename_all = "m.snake_case")]
pub enum CallScope {
/// A call which every user of a room can join and create.
///
/// There is no particular name associated with it.
///
/// There can only be one per room.
Room,
/// A user call is owned by a user.
///
/// Each user can create one there can be multiple per room. They are started and ended by the
/// owning user.
User,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}

View File

@ -1,4 +1,4 @@
//! Type for the matrixRTC notify event ([MSC4075]).
//! Type for the MatrixRTC notify event ([MSC4075]).
//!
//! [MSC4075]: https://github.com/matrix-org/matrix-spec-proposals/pull/4075
@ -75,3 +75,99 @@ impl From<Application> for ApplicationType {
}
}
}
#[cfg(test)]
mod tests {
use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
use crate::{
call::notify::{ApplicationType, CallNotifyEventContent, NotifyType},
Mentions,
};
#[test]
fn notify_event_serialization() {
use ruma_common::owned_user_id;
let content_user_mention = CallNotifyEventContent::new(
"abcdef".into(),
ApplicationType::Call,
NotifyType::Ring,
Mentions::with_user_ids(vec![
owned_user_id!("@user:example.com"),
owned_user_id!("@user2:example.com"),
]),
);
let content_room_mention = CallNotifyEventContent::new(
"abcdef".into(),
ApplicationType::Call,
NotifyType::Ring,
Mentions::with_room_mention(),
);
assert_eq!(
to_json_value(&content_user_mention).unwrap(),
json!({
"call_id": "abcdef",
"application": "m.call",
"m.mentions": {
"user_ids": ["@user2:example.com","@user:example.com"],
},
"notify_type": "ring",
})
);
assert_eq!(
to_json_value(&content_room_mention).unwrap(),
json!({
"call_id": "abcdef",
"application": "m.call",
"m.mentions": { "room": true },
"notify_type": "ring",
})
);
}
#[test]
fn notify_event_deserialization() {
use std::collections::BTreeSet;
use assert_matches2::assert_matches;
use ruma_common::owned_user_id;
use crate::{AnyMessageLikeEvent, MessageLikeEvent};
let json_data = json!({
"content": {
"call_id": "abcdef",
"application": "m.call",
"m.mentions": {
"room": false,
"user_ids": ["@user:example.com", "@user2:example.com"],
},
"notify_type": "ring",
},
"event_id": "$event:notareal.hs",
"origin_server_ts": 134_829_848,
"room_id": "!roomid:notareal.hs",
"sender": "@user:notareal.hs",
"type": "m.call.notify",
});
let event = from_json_value::<AnyMessageLikeEvent>(json_data).unwrap();
assert_matches!(
event,
AnyMessageLikeEvent::CallNotify(MessageLikeEvent::Original(message_event))
);
let content = message_event.content;
assert_eq!(content.call_id, "abcdef");
assert!(!content.mentions.room);
assert_eq!(
content.mentions.user_ids,
BTreeSet::from([
owned_user_id!("@user:example.com"),
owned_user_id!("@user2:example.com")
])
);
}
}

View File

@ -1,6 +1,6 @@
//! Types for the [`m.call.sdp_stream_metadata_changed`] event.
//!
//! [`m.call.sdp_stream_metadata_changed`]: https://github.com/matrix-org/matrix-spec-proposals/pull/3291
//! [`m.call.sdp_stream_metadata_changed`]: https://spec.matrix.org/latest/client-server-api/#mcallsdp_stream_metadata_changed
use std::collections::BTreeMap;
@ -15,7 +15,7 @@ use super::StreamMetadata;
/// This event is sent by any party when a stream metadata changes but no negotiation is required.
#[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_event(type = "org.matrix.call.sdp_stream_metadata_changed", kind = MessageLike)]
#[ruma_event(type = "m.call.sdp_stream_metadata_changed", alias = "org.matrix.call.sdp_stream_metadata_changed", kind = MessageLike)]
pub struct CallSdpStreamMetadataChangedEventContent {
/// A unique identifier for the call.
pub call_id: OwnedVoipId,

View File

@ -95,7 +95,6 @@ pub trait ToDeviceEventContent: EventContent<EventType = ToDeviceEventType> {}
/// Event content that can be deserialized with its event type.
pub trait EventContentFromType: EventContent {
/// Constructs this event content from the given event type and JSON.
#[doc(hidden)]
fn from_parts(event_type: &str, content: &RawJsonValue) -> serde_json::Result<Self>;
}

View File

@ -45,9 +45,8 @@ event_enum! {
"m.call.candidates" => super::call::candidates,
"m.call.negotiate" => super::call::negotiate,
"m.call.reject" => super::call::reject,
#[cfg(feature = "unstable-msc3291")]
#[ruma_enum(alias = "m.call.sdp_stream_metadata_changed")]
"org.matrix.call.sdp_stream_metadata_changed" => super::call::sdp_stream_metadata_changed,
#[ruma_enum(alias = "org.matrix.call.sdp_stream_metadata_changed")]
"m.call.sdp_stream_metadata_changed" => super::call::sdp_stream_metadata_changed,
"m.call.select_answer" => super::call::select_answer,
#[cfg(feature = "unstable-msc3954")]
#[ruma_enum(alias = "m.emote")]
@ -88,6 +87,9 @@ event_enum! {
#[cfg(feature = "unstable-msc3381")]
#[ruma_enum(ident = UnstablePollEnd)]
"org.matrix.msc3381.poll.end" => super::poll::unstable_end,
#[cfg(feature = "unstable-msc3489")]
#[ruma_enum(alias = "m.beacon")]
"org.matrix.msc3672.beacon" => super::beacon,
"m.reaction" => super::reaction,
"m.room.encrypted" => super::room::encrypted,
"m.room.message" => super::room::message,
@ -127,6 +129,9 @@ event_enum! {
"m.room.topic" => super::room::topic,
"m.space.child" => super::space::child,
"m.space.parent" => super::space::parent,
#[cfg(feature = "unstable-msc3489")]
#[ruma_enum(alias = "m.beacon_info")]
"org.matrix.msc3672.beacon_info" => super::beacon_info,
#[cfg(feature = "unstable-msc3401")]
#[ruma_enum(alias = "m.call.member")]
"org.matrix.msc3401.call.member" => super::call::member,
@ -309,6 +314,8 @@ impl AnyMessageLikeEventContent {
/// This is a helper function intended for encryption. There should not be a reason to access
/// `m.relates_to` without first destructuring an `AnyMessageLikeEventContent` otherwise.
pub fn relation(&self) -> Option<encrypted::Relation> {
#[cfg(feature = "unstable-msc3489")]
use super::beacon::BeaconEventContent;
use super::key::verification::{
accept::KeyVerificationAcceptEventContent, cancel::KeyVerificationCancelEventContent,
done::KeyVerificationDoneEventContent, key::KeyVerificationKeyEventContent,
@ -361,13 +368,16 @@ impl AnyMessageLikeEventContent {
| Self::UnstablePollEnd(UnstablePollEndEventContent { relates_to, .. }) => {
Some(encrypted::Relation::Reference(relates_to.clone()))
}
#[cfg(feature = "unstable-msc3489")]
Self::Beacon(BeaconEventContent { relates_to, .. }) => {
Some(encrypted::Relation::Reference(relates_to.clone()))
}
#[cfg(feature = "unstable-msc3381")]
Self::PollStart(_) | Self::UnstablePollStart(_) => None,
#[cfg(feature = "unstable-msc4075")]
Self::CallNotify(_) => None,
#[cfg(feature = "unstable-msc3291")]
Self::CallSdpStreamMetadataChanged(_) => None,
Self::CallNegotiate(_)
Self::CallSdpStreamMetadataChanged(_)
| Self::CallNegotiate(_)
| Self::CallReject(_)
| Self::CallSelectAnswer(_)
| Self::CallAnswer(_)

View File

@ -165,7 +165,10 @@ mod tests {
use std::collections::BTreeMap;
use assert_matches2::assert_matches;
use ruma_common::{event_id, serde::Base64};
use ruma_common::{
event_id,
serde::{Base64, Raw},
};
use serde_json::{
from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue,
};
@ -348,4 +351,26 @@ mod tests {
assert_eq!(sas.message_authentication_code, MessageAuthenticationCode::HkdfHmacSha256V2);
assert_eq!(sas.short_authentication_string, vec![ShortAuthenticationString::Decimal]);
}
#[test]
fn in_room_serialization_roundtrip() {
let event_id = event_id!("$1598361704261elfgc:localhost");
let content = KeyVerificationAcceptEventContent {
relates_to: Reference { event_id: event_id.to_owned() },
method: AcceptMethod::SasV1(SasV1Content {
hash: HashAlgorithm::Sha256,
key_agreement_protocol: KeyAgreementProtocol::Curve25519,
message_authentication_code: MessageAuthenticationCode::HkdfHmacSha256V2,
short_authentication_string: vec![ShortAuthenticationString::Decimal],
commitment: Base64::new(b"hello".to_vec()),
}),
};
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.method, AcceptMethod::SasV1(_));
assert_eq!(deser_content.relates_to.event_id, event_id);
}
}

View File

@ -139,6 +139,10 @@ pub mod macros {
#[cfg(feature = "unstable-msc3927")]
pub mod audio;
#[cfg(feature = "unstable-msc3489")]
pub mod beacon;
#[cfg(feature = "unstable-msc3489")]
pub mod beacon_info;
pub mod call;
pub mod direct;
pub mod dummy;

View File

@ -37,7 +37,7 @@ impl From<Annotation> for ReactionEventContent {
#[cfg(test)]
mod tests {
use assert_matches2::assert_matches;
use ruma_common::owned_event_id;
use ruma_common::{owned_event_id, serde::Raw};
use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
use super::ReactionEventContent;
@ -79,4 +79,18 @@ mod tests {
})
);
}
#[test]
fn serialization_roundtrip() {
let content = ReactionEventContent::new(Annotation::new(
owned_event_id!("$my_reaction"),
"🏠".to_owned(),
));
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_eq!(deser_content.relates_to.event_id, content.relates_to.event_id);
assert_eq!(deser_content.relates_to.key, content.relates_to.key);
}
}

View File

@ -165,6 +165,7 @@ impl<C> From<message::Relation<C>> for Relation {
/// [replaces another event]: https://spec.matrix.org/latest/client-server-api/#event-replacements
#[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "rel_type", rename = "m.replace")]
pub struct Replacement {
/// The ID of the event being replaced.
pub event_id: OwnedEventId,

View File

@ -74,27 +74,11 @@ impl Serialize for Relation {
st.serialize_field("m.in_reply_to", in_reply_to)?;
st.end()
}
Relation::Replacement(data) => {
RelationSerHelper { rel_type: "m.replace", data }.serialize(serializer)
}
Relation::Reference(data) => {
RelationSerHelper { rel_type: "m.reference", data }.serialize(serializer)
}
Relation::Annotation(data) => {
RelationSerHelper { rel_type: "m.annotation", data }.serialize(serializer)
}
Relation::Thread(data) => {
RelationSerHelper { rel_type: "m.thread", data }.serialize(serializer)
}
Relation::Replacement(data) => data.serialize(serializer),
Relation::Reference(data) => data.serialize(serializer),
Relation::Annotation(data) => data.serialize(serializer),
Relation::Thread(data) => data.serialize(serializer),
Relation::_Custom(c) => c.serialize(serializer),
}
}
}
#[derive(Serialize)]
struct RelationSerHelper<'a, T> {
rel_type: &'a str,
#[serde(flatten)]
data: &'a T,
}

View File

@ -168,6 +168,7 @@ impl From<JoinRule> for SpaceRoomJoinRule {
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Restricted {
/// Allow rules which describe conditions that allow joining a room.
#[serde(default)]
pub allow: Vec<AllowRule>,
}
@ -324,6 +325,16 @@ mod tests {
assert_eq!(serde_json::to_string(&allow_rule).unwrap(), json);
}
#[test]
fn restricted_room_no_allow_field() {
let json = r#"{"join_rule":"restricted"}"#;
let join_rules: RoomJoinRulesEventContent = serde_json::from_str(json).unwrap();
assert_matches!(
join_rules,
RoomJoinRulesEventContent { join_rule: JoinRule::Restricted(_) }
);
}
#[test]
fn join_rule_to_space_room_join_rule() {
assert_eq!(SpaceRoomJoinRule::Invite, JoinRule::Invite.into());

View File

@ -157,7 +157,7 @@ pub(super) enum RelationSerHelper {
Replacement(ReplacementJsonRepr),
/// An event that belongs to a thread, with stable names.
#[serde(rename = "m.thread")]
#[serde(untagged)]
Thread(Thread),
/// An unknown relation type.

View File

@ -57,7 +57,7 @@ fn is_default_bits(val: &UInt) -> bool {
///
/// The only algorithm currently specified is `m.secret_storage.v1.aes-hmac-sha2`, so this
/// essentially represents `AesHmacSha2KeyDescription` in the
/// [spec](https://spec.matrix.org/latest/client-server-api/#msecret_storagev1aes-hmac-sha2).
/// [spec](https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2).
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[derive(Clone, Debug, Serialize, EventContent)]
#[ruma_event(type = "m.secret_storage.key.*", kind = GlobalAccountData)]
@ -137,7 +137,7 @@ impl SecretStorageEncryptionAlgorithm {
/// The key properties for the `m.secret_storage.v1.aes-hmac-sha2` algorithm.
///
/// Corresponds to the AES-specific properties of `AesHmacSha2KeyDescription` in the
/// [spec](https://spec.matrix.org/latest/client-server-api/#msecret_storagev1aes-hmac-sha2).
/// [spec](https://spec.matrix.org/v1.11/client-server-api/#msecret_storagev1aes-hmac-sha2).
#[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct SecretStorageV1AesHmacSha2Properties {
@ -182,7 +182,7 @@ mod tests {
PassPhrase, SecretStorageEncryptionAlgorithm, SecretStorageKeyEventContent,
SecretStorageV1AesHmacSha2Properties,
};
use crate::{EventContentFromType, GlobalAccountDataEvent};
use crate::{AnyGlobalAccountDataEvent, EventContentFromType, GlobalAccountDataEvent};
#[test]
fn key_description_serialization() {
@ -326,7 +326,7 @@ mod tests {
}
#[test]
fn event_serialization() {
fn event_content_serialization() {
let mut content = SecretStorageKeyEventContent::new(
"my_key_id".into(),
SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties {
@ -346,6 +346,31 @@ mod tests {
assert_eq!(to_json_value(&content).unwrap(), json);
}
#[test]
fn event_serialization() {
let mut content = SecretStorageKeyEventContent::new(
"my_key_id".into(),
SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties {
iv: Some(Base64::parse("YWJjZGVmZ2hpamtsbW5vcA").unwrap()),
mac: Some(Base64::parse("aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U").unwrap()),
}),
);
content.name = Some("my_key".to_owned());
let event = GlobalAccountDataEvent { content };
let json = json!({
"type": "m.secret_storage.key.my_key_id",
"content": {
"name": "my_key",
"algorithm": "m.secret_storage.v1.aes-hmac-sha2",
"iv": "YWJjZGVmZ2hpamtsbW5vcA",
"mac": "aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U"
}
});
assert_eq!(to_json_value(&event).unwrap(), json);
}
#[test]
fn event_deserialization() {
let json = json!({
@ -358,8 +383,8 @@ mod tests {
}
});
let ev =
from_json_value::<GlobalAccountDataEvent<SecretStorageKeyEventContent>>(json).unwrap();
let any_ev = from_json_value::<AnyGlobalAccountDataEvent>(json).unwrap();
assert_matches!(any_ev, AnyGlobalAccountDataEvent::SecretStorageKey(ev));
assert_eq!(ev.content.key_id, "my_key_id");
assert_eq!(ev.content.name.unwrap(), "my_key");
assert_matches!(ev.content.passphrase, None);

View File

@ -0,0 +1,81 @@
#![cfg(feature = "unstable-msc3489")]
use assert_matches2::assert_matches;
use js_int::uint;
use ruma_common::{
owned_event_id, room_id, serde::CanBeEmpty, user_id, MilliSecondsSinceUnixEpoch,
};
use ruma_events::{
beacon::BeaconEventContent, relation::Reference, AnyMessageLikeEvent, MessageLikeEvent,
};
use serde_json::{
from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue,
};
fn get_beacon_event_content() -> BeaconEventContent {
BeaconEventContent::new(
owned_event_id!("$beacon_info_event_id:example.com"),
"geo:51.5008,0.1247;u=35".to_owned(),
Some(MilliSecondsSinceUnixEpoch(uint!(1_636_829_458))),
)
}
fn get_beacon_event_content_json() -> JsonValue {
json!({
"m.relates_to": {
"rel_type": "m.reference",
"event_id": "$beacon_info_event_id:example.com"
},
"org.matrix.msc3488.location": {
"uri": "geo:51.5008,0.1247;u=35",
},
"org.matrix.msc3488.ts": 1_636_829_458
})
}
#[test]
fn beacon_event_content_serialization() {
let event_content = get_beacon_event_content();
assert_eq!(to_json_value(&event_content).unwrap(), get_beacon_event_content_json());
}
#[test]
fn beacon_event_content_deserialization() {
let json_data = get_beacon_event_content_json();
let event_content: BeaconEventContent =
from_json_value::<BeaconEventContent>(json_data).unwrap();
assert_eq!(
event_content.relates_to.event_id,
owned_event_id!("$beacon_info_event_id:example.com")
);
assert_eq!(event_content.location.uri, "geo:51.5008,0.1247;u=35");
assert_eq!(event_content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458)));
}
#[test]
fn message_event_deserialization() {
let json_data = json!({
"content": get_beacon_event_content_json(),
"event_id": "$beacon_event_id:example.com",
"origin_server_ts": 1_636_829_458,
"room_id": "!roomid:example.com",
"type": "org.matrix.msc3672.beacon",
"sender": "@example:example.com"
});
let event = from_json_value::<AnyMessageLikeEvent>(json_data).unwrap();
assert_matches!(event, AnyMessageLikeEvent::Beacon(MessageLikeEvent::Original(ev)));
assert_eq!(ev.content.location.uri, "geo:51.5008,0.1247;u=35");
assert_eq!(ev.content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458)));
assert_matches!(ev.content.relates_to, Reference { event_id, .. });
assert_eq!(event_id, owned_event_id!("$beacon_info_event_id:example.com"));
assert_eq!(ev.sender, user_id!("@example:example.com"));
assert_eq!(ev.room_id, room_id!("!roomid:example.com"));
assert_eq!(ev.origin_server_ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458)));
assert!(ev.unsigned.is_empty());
}

View File

@ -0,0 +1,161 @@
#![cfg(feature = "unstable-msc3489")]
use std::time::Duration;
use assert_matches2::assert_matches;
use js_int::uint;
use ruma_common::{event_id, room_id, serde::CanBeEmpty, user_id, MilliSecondsSinceUnixEpoch};
use ruma_events::{
beacon_info::BeaconInfoEventContent, location::AssetType, AnyStateEvent, StateEvent,
};
use serde_json::{from_value as from_json_value, json, to_value as to_json_value};
fn get_beacon_info_event_content(
duration: Option<Duration>,
ts: Option<MilliSecondsSinceUnixEpoch>,
) -> BeaconInfoEventContent {
let description = Some("Kylie's live location".to_owned());
let duration_or = duration.unwrap_or(Duration::from_secs(60));
let ts_or = Some(ts.unwrap_or(MilliSecondsSinceUnixEpoch::now()));
BeaconInfoEventContent::new(description, duration_or, true, ts_or)
}
fn get_beacon_info_json() -> serde_json::Value {
json!({
"org.matrix.msc3488.ts": 1_636_829_458,
"org.matrix.msc3488.asset": {
"type": "m.self"
},
"timeout": 60_000,
"description": "Kylie's live location",
"live": true
})
}
#[test]
fn beacon_info_is_live() {
let event_content = get_beacon_info_event_content(None, None);
assert!(event_content.is_live());
}
#[test]
fn beacon_info_is_not_live() {
let duration = Some(Duration::from_nanos(1));
let event_content = get_beacon_info_event_content(duration, None);
assert!(!event_content.is_live());
}
#[test]
fn beacon_info_stop_event() {
let ts = Some(MilliSecondsSinceUnixEpoch(1_636_829_458_u64.try_into().unwrap()));
let mut event_content = get_beacon_info_event_content(None, ts);
event_content.stop();
assert_eq!(
to_json_value(&event_content).unwrap(),
json!({
"org.matrix.msc3488.ts": 1_636_829_458,
"org.matrix.msc3488.asset": {
"type": "m.self"
},
"timeout": 60_000,
"description": "Kylie's live location",
"live": false
})
);
}
#[test]
fn beacon_info_start_event() {
let ts = Some(MilliSecondsSinceUnixEpoch(1_636_829_458_u64.try_into().unwrap()));
let mut event_content = BeaconInfoEventContent::new(
Some("Kylie's live location".to_owned()),
Duration::from_secs(60),
false,
ts,
);
event_content.start();
assert_eq!(
to_json_value(&event_content).unwrap(),
json!({
"org.matrix.msc3488.ts": 1_636_829_458,
"org.matrix.msc3488.asset": {
"type": "m.self"
},
"timeout": 60_000,
"description": "Kylie's live location",
"live": true
})
);
}
#[test]
fn beacon_info_start_event_content_serialization() {
let ts = Some(MilliSecondsSinceUnixEpoch(1_636_829_458_u64.try_into().unwrap()));
let event_content = get_beacon_info_event_content(None, ts);
assert_eq!(
to_json_value(&event_content).unwrap(),
json!({
"org.matrix.msc3488.ts": 1_636_829_458,
"org.matrix.msc3488.asset": {
"type": "m.self"
},
"timeout": 60_000,
"description": "Kylie's live location",
"live": true
})
);
}
#[test]
fn beacon_info_start_event_content_deserialization() {
let json_data = get_beacon_info_json();
let event_content: BeaconInfoEventContent = serde_json::from_value(json_data).unwrap();
assert_eq!(event_content.description, Some("Kylie's live location".to_owned()));
assert!(event_content.live);
assert_eq!(event_content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458)));
assert_eq!(event_content.timeout, Duration::from_secs(60));
assert_eq!(event_content.asset.type_, AssetType::Self_);
}
#[test]
fn state_event_deserialization() {
let json_data = json!({
"content": get_beacon_info_json(),
"event_id": "$beacon_event_id:example.com",
"origin_server_ts": 1_636_829_458,
"room_id": "!roomid:example.com",
"type": "org.matrix.msc3672.beacon_info",
"sender": "@example:example.com",
"state_key": "@example:example.com"
});
let event = from_json_value::<AnyStateEvent>(json_data).unwrap();
assert_matches!(event, AnyStateEvent::BeaconInfo(StateEvent::Original(ev)));
assert_eq!(ev.content.description, Some("Kylie's live location".to_owned()));
assert_eq!(ev.content.ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458)));
assert_eq!(ev.content.timeout, Duration::from_secs(60));
assert_eq!(ev.content.asset.type_, AssetType::Self_);
assert!(ev.content.live);
assert_eq!(ev.event_id, event_id!("$beacon_event_id:example.com"));
assert_eq!(ev.origin_server_ts, MilliSecondsSinceUnixEpoch(uint!(1_636_829_458)));
assert_eq!(ev.room_id, room_id!("!roomid:example.com"));
assert_eq!(ev.sender, user_id!("@example:example.com"));
assert_eq!(ev.state_key, "@example:example.com");
assert!(ev.unsigned.is_empty());
}

View File

@ -1,6 +1,3 @@
#[cfg(feature = "unstable-msc4075")]
use std::collections::BTreeSet;
use assert_matches2::assert_matches;
#[cfg(feature = "unstable-msc2747")]
use assign::assign;
@ -8,11 +5,6 @@ use js_int::uint;
use ruma_common::{room_id, serde::CanBeEmpty, MilliSecondsSinceUnixEpoch, VoipVersionId};
#[cfg(feature = "unstable-msc2747")]
use ruma_events::call::CallCapabilities;
#[cfg(feature = "unstable-msc4075")]
use ruma_events::{
call::notify::{ApplicationType, CallNotifyEventContent, NotifyType},
Mentions,
};
use ruma_events::{
call::{
answer::CallAnswerEventContent,
@ -616,83 +608,3 @@ fn select_v1_answer_event_deserialization() {
assert_eq!(content.selected_party_id, "6336");
assert_eq!(content.version, VoipVersionId::V1);
}
#[cfg(feature = "unstable-msc4075")]
#[test]
fn notify_event_serialization() {
use ruma_common::owned_user_id;
let content_user_mention = CallNotifyEventContent::new(
"abcdef".into(),
ApplicationType::Call,
NotifyType::Ring,
Mentions::with_user_ids(vec![
owned_user_id!("@user:example.com"),
owned_user_id!("@user2:example.com"),
]),
);
let content_room_mention = CallNotifyEventContent::new(
"abcdef".into(),
ApplicationType::Call,
NotifyType::Ring,
Mentions::with_room_mention(),
);
assert_eq!(
to_json_value(&content_user_mention).unwrap(),
json!({
"call_id": "abcdef",
"application": "m.call",
"m.mentions": {
"user_ids": ["@user2:example.com","@user:example.com"],
},
"notify_type": "ring",
})
);
assert_eq!(
to_json_value(&content_room_mention).unwrap(),
json!({
"call_id": "abcdef",
"application": "m.call",
"m.mentions": { "room": true },
"notify_type": "ring",
})
);
}
#[cfg(feature = "unstable-msc4075")]
#[test]
fn notify_event_deserialization() {
use ruma_common::owned_user_id;
let json_data = json!({
"content": {
"call_id": "abcdef",
"application": "m.call",
"m.mentions": {
"room": false,
"user_ids": ["@user:example.com", "@user2:example.com"],
},
"notify_type": "ring",
},
"event_id": "$event:notareal.hs",
"origin_server_ts": 134_829_848,
"room_id": "!roomid:notareal.hs",
"sender": "@user:notareal.hs",
"type": "m.call.notify",
});
let event = from_json_value::<AnyMessageLikeEvent>(json_data).unwrap();
assert_matches!(
event,
AnyMessageLikeEvent::CallNotify(MessageLikeEvent::Original(message_event))
);
let content = message_event.content;
assert_eq!(content.call_id, "abcdef");
assert!(!content.mentions.room);
assert_eq!(
content.mentions.user_ids,
BTreeSet::from([owned_user_id!("@user:example.com"), owned_user_id!("@user2:example.com")])
);
}

View File

@ -1,7 +1,7 @@
use assert_matches2::assert_matches;
use ruma_common::{owned_device_id, owned_event_id};
use ruma_common::{owned_device_id, owned_event_id, serde::Raw};
use ruma_events::{
relation::{CustomRelation, InReplyTo, Reference, Thread},
relation::{Annotation, CustomRelation, InReplyTo, Reference, Thread},
room::encrypted::{
EncryptedEventScheme, MegolmV1AesSha2ContentInit, Relation, Replacement,
RoomEncryptedEventContent,
@ -82,6 +82,17 @@ fn content_no_relation_deserialization() {
assert_matches!(content.relates_to, None);
}
#[test]
fn content_no_relation_serialization_roundtrip() {
let content = RoomEncryptedEventContent::new(encrypted_scheme(), None);
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_));
assert_matches!(deser_content.relates_to, None);
}
#[test]
fn content_reply_serialization() {
let content = RoomEncryptedEventContent::new(
@ -149,6 +160,22 @@ fn content_reply_deserialization() {
assert_eq!(in_reply_to.event_id, "$replied_to_event");
}
#[test]
fn content_reply_serialization_roundtrip() {
let reply = InReplyTo::new(owned_event_id!("$replied_to_event"));
let content = RoomEncryptedEventContent::new(
encrypted_scheme(),
Some(Relation::Reply { in_reply_to: reply.clone() }),
);
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_));
assert_matches!(deser_content.relates_to, Some(Relation::Reply { in_reply_to: deser_reply }));
assert_eq!(deser_reply.event_id, reply.event_id);
}
#[test]
fn content_replacement_serialization() {
let content = RoomEncryptedEventContent::new(
@ -214,6 +241,22 @@ fn content_replacement_deserialization() {
assert_eq!(replacement.event_id, "$replaced_event");
}
#[test]
fn content_replacement_serialization_roundtrip() {
let replacement = Replacement::new(owned_event_id!("$replaced_event"));
let content = RoomEncryptedEventContent::new(
encrypted_scheme(),
Some(Relation::Replacement(replacement.clone())),
);
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_));
assert_matches!(deser_content.relates_to, Some(Relation::Replacement(deser_replacement)));
assert_eq!(deser_replacement.event_id, replacement.event_id);
}
#[test]
fn content_reference_serialization() {
let content = RoomEncryptedEventContent::new(
@ -279,6 +322,22 @@ fn content_reference_deserialization() {
assert_eq!(reference.event_id, "$referenced_event");
}
#[test]
fn content_reference_serialization_roundtrip() {
let reference = Reference::new(owned_event_id!("$referenced_event"));
let content = RoomEncryptedEventContent::new(
encrypted_scheme(),
Some(Relation::Reference(reference.clone())),
);
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_));
assert_matches!(deser_content.relates_to, Some(Relation::Reference(deser_reference)));
assert_eq!(deser_reference.event_id, reference.event_id);
}
#[test]
fn content_thread_serialization() {
let content = RoomEncryptedEventContent::new(
@ -357,9 +416,23 @@ fn content_thread_deserialization() {
}
#[test]
fn content_annotation_serialization() {
use ruma_events::relation::Annotation;
fn content_thread_serialization_roundtrip() {
let thread = Thread::plain(owned_event_id!("$thread_root"), owned_event_id!("$prev_event"));
let content =
RoomEncryptedEventContent::new(encrypted_scheme(), Some(Relation::Thread(thread.clone())));
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_));
assert_matches!(deser_content.relates_to, Some(Relation::Thread(deser_thread)));
assert_eq!(deser_thread.event_id, thread.event_id);
assert_eq!(deser_thread.in_reply_to.unwrap().event_id, thread.in_reply_to.unwrap().event_id);
assert_eq!(deser_thread.is_falling_back, thread.is_falling_back);
}
#[test]
fn content_annotation_serialization() {
let content = RoomEncryptedEventContent::new(
encrypted_scheme(),
Some(Relation::Annotation(Annotation::new(
@ -429,6 +502,23 @@ fn content_annotation_deserialization() {
assert_eq!(annotation.key, "some_key");
}
#[test]
fn content_annotation_serialization_roundtrip() {
let annotation = Annotation::new(owned_event_id!("$annotated_event"), "some_key".to_owned());
let content = RoomEncryptedEventContent::new(
encrypted_scheme(),
Some(Relation::Annotation(annotation.clone())),
);
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_));
assert_matches!(deser_content.relates_to, Some(Relation::Annotation(deser_annotation)));
assert_eq!(deser_annotation.event_id, annotation.event_id);
assert_eq!(deser_annotation.key, annotation.key);
}
#[test]
fn custom_relation_deserialization() {
let relation_json = json!({
@ -502,3 +592,31 @@ fn custom_relation_serialization() {
})
);
}
#[test]
fn custom_serialization_roundtrip() {
let rel_type = "io.ruma.unknown";
let event_id = "$related_event";
let key = "value";
let json_relation = json!({
"rel_type": rel_type,
"event_id": event_id,
"key": key,
});
let relation = from_json_value::<CustomRelation>(json_relation).unwrap();
let content =
RoomEncryptedEventContent::new(encrypted_scheme(), Some(Relation::_Custom(relation)));
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(content.scheme, EncryptedEventScheme::MegolmV1AesSha2(_));
let deser_relates_to = deser_content.relates_to.unwrap();
assert_matches!(&deser_relates_to, Relation::_Custom(_));
assert_eq!(deser_relates_to.rel_type().unwrap().as_str(), rel_type);
let deser_relation = deser_relates_to.data();
assert_eq!(deser_relation.get("rel_type").unwrap().as_str().unwrap(), rel_type);
assert_eq!(deser_relation.get("event_id").unwrap().as_str().unwrap(), event_id);
assert_eq!(deser_relation.get("key").unwrap().as_str().unwrap(), key);
}

View File

@ -1,8 +1,15 @@
use assert_matches2::assert_matches;
use js_int::uint;
use ruma_common::{serde::CanBeEmpty, MilliSecondsSinceUnixEpoch, VoipVersionId};
use ruma_events::{AnyMessageLikeEvent, MessageLikeEvent};
use serde_json::{from_value as from_json_value, json};
use ruma_common::{
serde::{CanBeEmpty, Raw},
MilliSecondsSinceUnixEpoch, VoipVersionId,
};
use ruma_events::{
secret_storage::key::{SecretStorageEncryptionAlgorithm, SecretStorageV1AesHmacSha2Properties},
AnyGlobalAccountDataEventContent, AnyMessageLikeEvent, AnyMessageLikeEventContent,
MessageLikeEvent, RawExt as _,
};
use serde_json::{from_value as from_json_value, json, value::to_raw_value as to_raw_json_value};
#[test]
fn ui() {
@ -46,3 +53,53 @@ fn deserialize_message_event() {
assert_eq!(content.call_id, "foofoo");
assert_eq!(content.version, VoipVersionId::V0);
}
#[test]
fn text_msgtype_plain_text_deserialization_as_any() {
let serialized = json!({
"body": "Hello world!",
"msgtype": "m.text"
});
let raw_event: Raw<AnyMessageLikeEventContent> =
Raw::from_json_string(serialized.to_string()).unwrap();
let event = raw_event.deserialize_with_type("m.room.message".into()).unwrap();
assert_matches!(event, AnyMessageLikeEventContent::RoomMessage(content));
assert_eq!(content.body(), "Hello world!");
}
#[test]
fn secret_storage_key_deserialization_as_any() {
let serialized = to_raw_json_value(&json!({
"name": "my_key",
"algorithm": "m.secret_storage.v1.aes-hmac-sha2",
"iv": "YWJjZGVmZ2hpamtsbW5vcA",
"mac": "aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U"
}))
.unwrap();
let raw_event: Raw<AnyGlobalAccountDataEventContent> =
Raw::from_json_string(serialized.to_string()).unwrap();
let event = raw_event.deserialize_with_type("m.secret_storage.key.test".into()).unwrap();
assert_matches!(event, AnyGlobalAccountDataEventContent::SecretStorageKey(content));
assert_eq!(content.name.unwrap(), "my_key");
assert_eq!(content.key_id, "test");
assert_matches!(content.passphrase, None);
assert_matches!(
content.algorithm,
SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties {
iv: Some(iv),
mac: Some(mac),
..
})
);
assert_eq!(iv.encode(), "YWJjZGVmZ2hpamtsbW5vcA");
assert_eq!(mac.encode(), "aWRvbnRrbm93d2hhdGFtYWNsb29rc2xpa2U");
}

View File

@ -1,4 +1,6 @@
mod audio;
mod beacon;
mod beacon_info;
mod call;
mod encrypted;
mod enums;

View File

@ -1,6 +1,6 @@
use assert_matches2::assert_matches;
use assign::assign;
use ruma_common::owned_event_id;
use ruma_common::{owned_event_id, serde::Raw};
use ruma_events::{
relation::{CustomRelation, InReplyTo, Replacement, Thread},
room::message::{MessageType, Relation, RoomMessageEventContent},
@ -52,6 +52,22 @@ fn reply_serialize() {
);
}
#[test]
fn reply_serialization_roundtrip() {
let body = "This is a reply";
let mut content = RoomMessageEventContent::text_plain(body);
let reply = InReplyTo::new(owned_event_id!("$1598361704261elfgc"));
content.relates_to = Some(Relation::Reply { in_reply_to: reply.clone() });
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg));
assert_eq!(deser_msg.body, body);
assert_matches!(content.relates_to.unwrap(), Relation::Reply { in_reply_to: deser_reply });
assert_eq!(deser_reply.event_id, reply.event_id);
}
#[test]
fn replacement_serialize() {
let content = assign!(
@ -111,6 +127,28 @@ fn replacement_deserialize() {
assert_eq!(text.body, "Hello! My name is bar");
}
#[test]
fn replacement_serialization_roundtrip() {
let body = "<text msg>";
let mut content = RoomMessageEventContent::text_plain(body);
let new_body = "This is the new content.";
let replacement = Replacement::new(
owned_event_id!("$1598361704261elfgc"),
RoomMessageEventContent::text_plain(new_body).into(),
);
content.relates_to = Some(Relation::Replacement(replacement.clone()));
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg));
assert_eq!(deser_msg.body, body);
assert_matches!(content.relates_to.unwrap(), Relation::Replacement(deser_replacement));
assert_eq!(deser_replacement.event_id, replacement.event_id);
assert_matches!(deser_replacement.new_content.msgtype, MessageType::Text(deser_new_msg));
assert_eq!(deser_new_msg.body, new_body);
}
#[test]
fn thread_plain_serialize() {
let content = assign!(
@ -250,6 +288,25 @@ fn thread_unstable_deserialize() {
assert!(!thread.is_falling_back);
}
#[test]
fn thread_serialization_roundtrip() {
let body = "<text msg>";
let mut content = RoomMessageEventContent::text_plain(body);
let thread =
Thread::plain(owned_event_id!("$1598361704261elfgc"), owned_event_id!("$latesteventid"));
content.relates_to = Some(Relation::Thread(thread.clone()));
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg));
assert_eq!(deser_msg.body, body);
assert_matches!(content.relates_to.unwrap(), Relation::Thread(deser_thread));
assert_eq!(deser_thread.event_id, thread.event_id);
assert_eq!(deser_thread.in_reply_to.unwrap().event_id, thread.in_reply_to.unwrap().event_id);
assert_eq!(deser_thread.is_falling_back, thread.is_falling_back);
}
#[test]
fn custom_deserialize() {
let relation_json = json!({
@ -300,3 +357,33 @@ fn custom_serialize() {
})
);
}
#[test]
fn custom_serialization_roundtrip() {
let rel_type = "io.ruma.unknown";
let event_id = "$related_event";
let key = "value";
let json_relation = json!({
"rel_type": rel_type,
"event_id": event_id,
"key": key,
});
let relation = from_json_value::<CustomRelation>(json_relation).unwrap();
let body = "<text msg>";
let mut content = RoomMessageEventContent::text_plain(body);
content.relates_to = Some(Relation::_Custom(relation));
let json_content = Raw::new(&content).unwrap();
let deser_content = json_content.deserialize().unwrap();
assert_matches!(deser_content.msgtype, MessageType::Text(deser_msg));
assert_eq!(deser_msg.body, body);
let deser_relates_to = deser_content.relates_to.unwrap();
assert_matches!(&deser_relates_to, Relation::_Custom(_));
assert_eq!(deser_relates_to.rel_type().unwrap().as_str(), rel_type);
let deser_relation = deser_relates_to.data();
assert_eq!(deser_relation.get("rel_type").unwrap().as_str().unwrap(), rel_type);
assert_eq!(deser_relation.get("event_id").unwrap().as_str().unwrap(), event_id);
assert_eq!(deser_relation.get("key").unwrap().as_str().unwrap(), key);
}

View File

@ -19,7 +19,7 @@ use ruma_events::{
},
EncryptedFileInit, JsonWebKeyInit, MediaSource,
},
AnySyncTimelineEvent, Mentions, MessageLikeUnsigned,
AnySyncTimelineEvent, EventContent, Mentions, MessageLikeUnsigned, RawExt,
};
use serde_json::{
from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue,
@ -484,6 +484,49 @@ fn reply_thread_fallback() {
assert!(thread_info.is_falling_back);
}
#[test]
fn reply_thread_serialization_roundtrip() {
let thread_root = OriginalRoomMessageEvent {
content: RoomMessageEventContent::text_plain("Thread root"),
event_id: owned_event_id!("$thread_root"),
origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)),
room_id: owned_room_id!("!testroomid:example.org"),
sender: owned_user_id!("@user:example.org"),
unsigned: MessageLikeUnsigned::default(),
};
let threaded_message = OriginalRoomMessageEvent {
content: RoomMessageEventContent::text_plain("Threaded message").make_for_thread(
&thread_root,
ReplyWithinThread::No,
AddMentions::No,
),
event_id: owned_event_id!("$threaded_message"),
origin_server_ts: MilliSecondsSinceUnixEpoch(uint!(10_000)),
room_id: owned_room_id!("!testroomid:example.org"),
sender: owned_user_id!("@user:example.org"),
unsigned: MessageLikeUnsigned::default(),
};
let reply_as_thread_fallback = RoomMessageEventContent::text_plain(
"Reply from a thread client",
)
.make_reply_to(&threaded_message, ForwardThread::Yes, AddMentions::No);
let as_raw = Raw::new(&reply_as_thread_fallback).unwrap();
let reply_as_thread_fallback =
as_raw.deserialize_with_type(reply_as_thread_fallback.event_type()).unwrap();
let relation = reply_as_thread_fallback.relates_to.unwrap();
assert_matches!(relation, Relation::Thread(thread_info));
assert_eq!(
thread_info.in_reply_to.map(|in_reply_to| in_reply_to.event_id),
Some(threaded_message.event_id)
);
assert_eq!(thread_info.event_id, thread_root.event_id);
assert!(thread_info.is_falling_back);
}
#[test]
fn reply_add_mentions() {
let user = owned_user_id!("@user:example.org");

View File

@ -33,7 +33,7 @@ pub mod v1 {
pub query_type: String,
/// The query parameters.
#[ruma_api(query_map)]
#[ruma_api(query_all)]
pub params: BTreeMap<String, String>,
}

View File

@ -1,5 +1,13 @@
# [unreleased]
Breaking Changes:
- `MatrixElement::Div` is now a newtype variant.
Improvements:
- Add support for mathematical messages, according to MSC2191 / Matrix 1.11
# 0.2.0
Breaking Changes:

View File

@ -152,7 +152,7 @@ pub enum MatrixElement {
/// [`<div>`], a content division element.
///
/// [`<div>`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div
Div,
Div(DivData),
/// [`<table>`], a table element.
///
@ -268,7 +268,10 @@ impl MatrixElement {
}
b"hr" => (Self::Hr, attrs.clone()),
b"br" => (Self::Br, attrs.clone()),
b"div" => (Self::Div, attrs.clone()),
b"div" => {
let (data, attrs) = DivData::parse(attrs);
(Self::Div(data), attrs)
}
b"table" => (Self::Table, attrs.clone()),
b"thead" => (Self::Thead, attrs.clone()),
b"tbody" => (Self::Tbody, attrs.clone()),
@ -599,12 +602,23 @@ pub struct SpanData {
///
/// [spoiler message]: https://spec.matrix.org/latest/client-server-api/#spoiler-messages
pub spoiler: Option<StrTendril>,
/// `data-mx-maths`, an inline Matrix [mathematical message].
///
/// The value is the mathematical notation in [LaTeX] format.
///
/// If this attribute is present, the content of the span is the fallback representation of the
/// mathematical notation.
///
/// [mathematical message]: https://spec.matrix.org/latest/client-server-api/#mathematical-messages
/// [LaTeX]: https://www.latex-project.org/
pub maths: Option<StrTendril>,
}
impl SpanData {
/// Construct an empty `SpanData`.
fn new() -> Self {
Self { bg_color: None, color: None, spoiler: None }
Self { bg_color: None, color: None, spoiler: None, maths: None }
}
/// Parse the given attributes to construct a new `SpanData`.
@ -629,6 +643,9 @@ impl SpanData {
b"data-mx-spoiler" => {
data.spoiler = Some(attr.value.clone());
}
b"data-mx-maths" => {
data.maths = Some(attr.value.clone());
}
_ => {
remaining_attrs.insert(attr.clone());
}
@ -722,3 +739,53 @@ impl ImageData {
(data, remaining_attrs)
}
}
/// The supported data of a `<div>` HTML element.
#[derive(Debug, Clone)]
#[non_exhaustive]
pub struct DivData {
/// `data-mx-maths`, a Matrix [mathematical message] block.
///
/// The value is the mathematical notation in [LaTeX] format.
///
/// If this attribute is present, the content of the div is the fallback representation of the
/// mathematical notation.
///
/// [mathematical message]: https://spec.matrix.org/latest/client-server-api/#mathematical-messages
/// [LaTeX]: https://www.latex-project.org/
pub maths: Option<StrTendril>,
}
impl DivData {
/// Construct an empty `DivData`.
fn new() -> Self {
Self { maths: None }
}
/// Parse the given attributes to construct a new `SpanData`.
///
/// Returns a tuple containing the constructed data and the remaining unsupported attributes.
#[allow(clippy::mutable_key_type)]
fn parse(attrs: &BTreeSet<Attribute>) -> (Self, BTreeSet<Attribute>) {
let mut data = Self::new();
let mut remaining_attrs = BTreeSet::new();
for attr in attrs {
if attr.name.ns != ns!() {
remaining_attrs.insert(attr.clone());
continue;
}
match attr.name.local.as_bytes() {
b"data-mx-maths" => {
data.maths = Some(attr.value.clone());
}
_ => {
remaining_attrs.insert(attr.clone());
}
}
}
(data, remaining_attrs)
}
}

View File

@ -28,14 +28,16 @@ static ALLOWED_ATTRIBUTES_STRICT: Map<&str, &Set<&str>> = phf_map! {
"img" => &ALLOWED_ATTRIBUTES_IMG_STRICT,
"ol" => &ALLOWED_ATTRIBUTES_OL_STRICT,
"code" => &ALLOWED_ATTRIBUTES_CODE_STRICT,
"div" => &ALLOWED_ATTRIBUTES_DIV_STRICT,
};
static ALLOWED_ATTRIBUTES_SPAN_STRICT: Set<&str> =
phf_set! { "data-mx-bg-color", "data-mx-color", "data-mx-spoiler" };
phf_set! { "data-mx-bg-color", "data-mx-color", "data-mx-spoiler", "data-mx-maths" };
static ALLOWED_ATTRIBUTES_A_STRICT: Set<&str> = phf_set! { "name", "target", "href" };
static ALLOWED_ATTRIBUTES_IMG_STRICT: Set<&str> =
phf_set! { "width", "height", "alt", "title", "src" };
static ALLOWED_ATTRIBUTES_OL_STRICT: Set<&str> = phf_set! { "start" };
static ALLOWED_ATTRIBUTES_CODE_STRICT: Set<&str> = phf_set! { "class" };
static ALLOWED_ATTRIBUTES_DIV_STRICT: Set<&str> = phf_set! { "data-mx-maths" };
/// Attributes that were previously allowed on HTML elements according to the Matrix specification,
/// with their replacement.

View File

@ -26,7 +26,8 @@ fn elements() {
// `<div>` element.
let div_node = html_children.next().unwrap();
let div_element = div_node.as_element().unwrap().to_matrix();
assert_matches!(div_element.element, MatrixElement::Div);
assert_matches!(div_element.element, MatrixElement::Div(div));
assert_eq!(div.maths, None);
// The `class` attribute is not supported.
assert_eq!(div_element.attrs.len(), 1);

View File

@ -5,10 +5,8 @@
Bug fixes:
- Allow underscores (`_`) when validating MXC URIs.
- They have always been allowed in [the spec][mxc validation spec]
in order to support URL-safe base64-encoded media IDs.
[mxc validation spec]: https://spec.matrix.org/v1.9/client-server-api/#security-considerations-5
- They have always been allowed in the spec in order to support URL-safe
base64-encoded media IDs.
Improvements:

View File

@ -77,7 +77,7 @@ pub enum MxcUriError {
/// Media identifier malformed due to invalid characters detected.
///
/// Valid characters are (in regex notation) `[A-Za-z0-9_-]+`.
/// See [here](https://spec.matrix.org/v1.10/client-server-api/#security-considerations-5) for more details.
/// See [here](https://spec.matrix.org/v1.11/client-server-api/#security-considerations-5) for more details.
#[error("Media Identifier malformed, invalid characters")]
MediaIdMalformed,

View File

@ -17,7 +17,7 @@ pub fn validate(uri: &str) -> Result<NonZeroU8, MxcUriError> {
let server_name = &uri[..index];
let media_id = &uri[index + 1..];
// See: https://spec.matrix.org/v1.10/client-server-api/#security-considerations-5
// See: https://spec.matrix.org/v1.11/client-server-api/#security-considerations-5
let media_id_is_valid = media_id
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'-' | b'_' ));

View File

@ -10,7 +10,7 @@ mod kw {
syn::custom_keyword!(raw_body);
syn::custom_keyword!(path);
syn::custom_keyword!(query);
syn::custom_keyword!(query_map);
syn::custom_keyword!(query_all);
syn::custom_keyword!(header);
syn::custom_keyword!(error);
syn::custom_keyword!(manual_body_serde);
@ -22,7 +22,7 @@ pub enum RequestMeta {
RawBody,
Path,
Query,
QueryMap,
QueryAll,
Header(Ident),
}
@ -41,9 +41,9 @@ impl Parse for RequestMeta {
} else if lookahead.peek(kw::query) {
let _: kw::query = input.parse()?;
Ok(Self::Query)
} else if lookahead.peek(kw::query_map) {
let _: kw::query_map = input.parse()?;
Ok(Self::QueryMap)
} else if lookahead.peek(kw::query_all) {
let _: kw::query_all = input.parse()?;
Ok(Self::QueryAll)
} else if lookahead.peek(kw::header) {
let _: kw::header = input.parse()?;
let _: Token![=] = input.parse()?;

View File

@ -137,8 +137,8 @@ impl Request {
self.fields.iter().find_map(RequestField::as_raw_body_field)
}
fn query_map_field(&self) -> Option<&Field> {
self.fields.iter().find_map(RequestField::as_query_map_field)
fn query_all_field(&self) -> Option<&Field> {
self.fields.iter().find_map(RequestField::as_query_all_field)
}
fn expand_all(&self, ruma_common: &TokenStream) -> TokenStream {
@ -161,7 +161,7 @@ impl Request {
}
});
let request_query_def = if let Some(f) = self.query_map_field() {
let request_query_def = if let Some(f) = self.query_all_field() {
let field = Field { ident: None, colon_token: None, ..f.clone() };
let field = PrivateField(&field);
Some(quote! { (#field); })
@ -220,15 +220,15 @@ impl Request {
}
};
let query_map_fields =
self.fields.iter().filter(|f| matches!(&f.kind, RequestFieldKind::QueryMap));
let has_query_map_field = match query_map_fields.count() {
let query_all_fields =
self.fields.iter().filter(|f| matches!(&f.kind, RequestFieldKind::QueryAll));
let has_query_all_field = match query_all_fields.count() {
0 => false,
1 => true,
_ => {
return Err(syn::Error::new_spanned(
&self.ident,
"Can't have more than one query_map field",
"Can't have more than one query_all field",
))
}
};
@ -244,10 +244,10 @@ impl Request {
));
}
if has_query_map_field && has_query_fields {
if has_query_all_field && has_query_fields {
return Err(syn::Error::new_spanned(
&self.ident,
"Can't have both a query map field and regular query fields",
"Can't have both a query_all field and regular query fields",
));
}
@ -307,8 +307,8 @@ pub(super) enum RequestFieldKind {
/// Data that appears in the query string.
Query,
/// Data that appears in the query string as dynamic key-value pairs.
QueryMap,
/// Data that represents all the query string as a single type.
QueryAll,
}
impl RequestField {
@ -319,7 +319,7 @@ impl RequestField {
Some(RequestMeta::RawBody) => RequestFieldKind::RawBody,
Some(RequestMeta::Path) => RequestFieldKind::Path,
Some(RequestMeta::Query) => RequestFieldKind::Query,
Some(RequestMeta::QueryMap) => RequestFieldKind::QueryMap,
Some(RequestMeta::QueryAll) => RequestFieldKind::QueryAll,
Some(RequestMeta::Header(header)) => RequestFieldKind::Header(header),
None => RequestFieldKind::Body,
};
@ -359,10 +359,10 @@ impl RequestField {
}
}
/// Return the contained field if this request field is a query map kind.
pub fn as_query_map_field(&self) -> Option<&Field> {
/// Return the contained field if this request field is a query all kind.
pub fn as_query_all_field(&self) -> Option<&Field> {
match &self.kind {
RequestFieldKind::QueryMap => Some(&self.inner),
RequestFieldKind::QueryAll => Some(&self.inner),
_ => None,
}
}

View File

@ -31,7 +31,7 @@ impl Request {
(TokenStream::new(), TokenStream::new())
};
let (parse_query, query_vars) = if let Some(field) = self.query_map_field() {
let (parse_query, query_vars) = if let Some(field) = self.query_all_field() {
let cfg_attrs =
field.attrs.iter().filter(|a| a.path().is_ident("cfg")).collect::<Vec<_>>();
let field_name = field.ident.as_ref().expect("expected field to have an identifier");

View File

@ -15,29 +15,11 @@ impl Request {
let path_fields =
self.path_fields().map(|f| f.ident.as_ref().expect("path fields have a name"));
let request_query_string = if let Some(field) = self.query_map_field() {
let request_query_string = if let Some(field) = self.query_all_field() {
let field_name = field.ident.as_ref().expect("expected field to have identifier");
quote! {{
// This function exists so that the compiler will throw an error when the type of
// the field with the query_map attribute doesn't implement
// `IntoIterator<Item = (String, String)>`.
//
// This is necessary because the `serde_html_form::to_string` call will result in a
// runtime error when the type cannot be encoded as a list key-value pairs
// (?key1=value1&key2=value2).
//
// By asserting that it implements the iterator trait, we can ensure that it won't
// fail.
fn assert_trait_impl<T>(_: &T)
where
T: ::std::iter::IntoIterator<
Item = (::std::string::String, ::std::string::String),
>,
{}
let request_query = RequestQuery(self.#field_name);
assert_trait_impl(&request_query.0);
&#serde_html_form::to_string(request_query)?
}}

View File

@ -170,7 +170,17 @@ fn expand_deserialize_impl(
};
let self_variant = variant.ctor(quote! { Self });
let content = event.to_event_path(kind, var);
let ev_types = event.aliases.iter().chain([&event.ev_type]);
let ev_types = event.aliases.iter().chain([&event.ev_type]).map(|ev_type| {
if event.has_type_fragment() {
let ev_type = ev_type.value();
let prefix = ev_type
.strip_suffix('*')
.expect("event type with type fragment must end with *");
quote! { t if t.starts_with(#prefix) }
} else {
quote! { #ev_type }
}
});
Ok(quote! {
#variant_attrs #(#ev_types)|* => {
@ -328,6 +338,55 @@ fn expand_content_enum(
let serialize_custom_event_error_path =
quote! { #ruma_events::serialize_custom_event_error }.to_string();
// Generate an `EventContentFromType` implementation.
let serde_json = quote! { #ruma_events::exports::serde_json };
let event_type_match_arms: TokenStream = events
.iter()
.map(|event| {
let variant = event.to_variant()?;
let variant_attrs = {
let attrs = &variant.attrs;
quote! { #(#attrs)* }
};
let self_variant = variant.ctor(quote! { Self });
let ev_types = event.aliases.iter().chain([&event.ev_type]).map(|ev_type| {
if event.has_type_fragment() {
let ev_type = ev_type.value();
let prefix = ev_type
.strip_suffix('*')
.expect("event type with type fragment must end with *");
quote! { t if t.starts_with(#prefix) }
} else {
quote! { #ev_type }
}
});
let deserialize_content = if event.has_type_fragment() {
// If the event has a type fragment, then it implements EventContentFromType itself;
// see `generate_event_content_impl` which does that. In this case, forward to its
// implementation.
let content_type = event.to_event_content_path(kind, None);
quote! {
#content_type::from_parts(event_type, json)?
}
} else {
// The event doesn't have a type fragment, so it *should* implement Deserialize:
// use that here.
quote! {
#serde_json::from_str(json.get())?
}
};
Ok(quote! {
#variant_attrs #(#ev_types)|* => {
let content = #deserialize_content;
Ok(#self_variant(content))
},
})
})
.collect::<syn::Result<_>>()?;
Ok(quote! {
#( #attrs )*
#[derive(Clone, Debug, #serde::Serialize)]
@ -358,6 +417,23 @@ fn expand_content_enum(
}
}
#[automatically_derived]
impl #ruma_events::EventContentFromType for #ident {
fn from_parts(event_type: &str, json: &#serde_json::value::RawValue) -> serde_json::Result<Self> {
match event_type {
#event_type_match_arms
_ => {
Ok(Self::_Custom {
event_type: crate::PrivOwnedStr(
::std::convert::From::from(event_type.to_owned())
)
})
}
}
}
}
#[automatically_derived]
impl #ruma_events::#sub_trait_name for #ident {
#state_event_content_impl

View File

@ -4,6 +4,19 @@ Breaking changes:
- The `XMatrix::new` method now takes `OwnedServerName` instead of `Option<OwnedServerName>`
for the destination, since servers must always set the destination.
- The `sig` field in `XMatrix` has been changed from `String` to `Base64` to more accurately
mirror its allowed values in the type system.
Bug fixes:
- When encoding to a header value, `XMatrix` fields are now quoted and escaped correctly.
- Use http-auth crate to parse `XMatrix`. Allows to parse the Authorization HTTP
header with full compatibility with RFC 7235
Improvements:
- Implement `Display`, `FromStr` and conversion to/from `http::HeaderValue` for
`XMatrix`
# 0.3.0

View File

@ -16,9 +16,11 @@ all-features = true
[dependencies]
headers = "0.4.0"
http = { workspace = true }
http-auth = { version = "0.1.9", default-features = false }
ruma-common = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
yap = "0.12.0"
[dev-dependencies]
tracing-subscriber = "0.3.16"

View File

@ -1,30 +1,38 @@
//! Common types for implementing federation authorization.
use headers::{authorization::Credentials, HeaderValue};
use ruma_common::{OwnedServerName, OwnedServerSigningKeyId};
use std::{borrow::Cow, fmt, str::FromStr};
use headers::authorization::Credentials;
use http::HeaderValue;
use http_auth::ChallengeParser;
use ruma_common::{
serde::{Base64, Base64DecodeError},
IdParseError, OwnedServerName, OwnedServerSigningKeyId,
};
use thiserror::Error;
use tracing::debug;
use yap::{IntoTokens, TokenLocation, Tokens};
/// Typed representation of an `Authorization` header of scheme `X-Matrix`, as defined in the
/// [Matrix Server-Server API][spec]. Includes an implementation of
/// [`headers::authorization::Credentials`] for automatically handling the encoding and decoding
/// when using a web framework that supports typed headers.
/// [Matrix Server-Server API][spec].
///
/// [spec]: https://spec.matrix.org/latest/server-server-api/#request-authentication
#[derive(Clone)]
#[non_exhaustive]
pub struct XMatrix {
/// The server name of the sending server.
pub origin: OwnedServerName,
/// The server name of the receiving sender. For compatibility with older servers, recipients
/// should accept requests without this parameter, but MUST always send it. If this property is
/// included, but the value does not match the receiving server's name, the receiving server
/// must deny the request with an HTTP status code 401 Unauthorized.
/// The server name of the receiving sender.
///
/// For compatibility with older servers, recipients should accept requests without this
/// parameter, but MUST always send it. If this property is included, but the value does
/// not match the receiving server's name, the receiving server must deny the request with
/// an HTTP status code 401 Unauthorized.
pub destination: Option<OwnedServerName>,
/// The ID - including the algorithm name - of the sending server's key that was used to sign
/// the request.
pub key: OwnedServerSigningKeyId,
/// The signature of the JSON.
pub sig: String,
pub sig: Base64,
}
impl XMatrix {
@ -33,227 +41,214 @@ impl XMatrix {
origin: OwnedServerName,
destination: OwnedServerName,
key: OwnedServerSigningKeyId,
sig: String,
sig: Base64,
) -> Self {
Self { origin, destination: Some(destination), key, sig }
}
}
fn parse_token<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> {
tokens.optional(|t| {
let token: Vec<u8> = t.take_while(|c| is_tchar(**c)).as_iter().copied().collect();
if !token.is_empty() {
Some(token)
} else {
debug!("Returning early because of empty token at {}", t.location().offset());
None
}
})
}
/// Parse an X-Matrix Authorization header from the given string.
pub fn parse(s: impl AsRef<str>) -> Result<Self, XMatrixParseError> {
let parser = ChallengeParser::new(s.as_ref());
let mut xmatrix = None;
fn parse_token_with_colons<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> {
tokens.optional(|t| {
let token: Vec<u8> =
t.take_while(|c| is_tchar(**c) || **c == b':').as_iter().copied().collect();
if !token.is_empty() {
Some(token)
} else {
debug!("Returning early because of empty token at {}", t.location().offset());
None
}
})
}
for challenge in parser {
let challenge = challenge?;
fn parse_quoted<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> {
tokens.optional(|t| {
if !(t.token(&b'"')) {
return None;
}
let mut buffer = Vec::new();
loop {
match t.next()? {
// quoted pair
b'\\' => {
let escaped = t.next().filter(|c| {
if is_quoted_pair(**c) {
true
} else {
debug!(
"Encountered an illegal character {} at location {}",
**c as char,
t.location().offset()
);
false
}
})?;
buffer.push(*escaped);
}
// end of quote
b'"' => break,
// regular character
c if is_qdtext(*c) => buffer.push(*c),
// Invalid character
c => {
debug!(
"Encountered an illegal character {} at location {}",
*c as char,
t.location().offset()
);
return None;
}
if challenge.scheme.eq_ignore_ascii_case(XMatrix::SCHEME) {
xmatrix = Some(challenge);
break;
}
}
Some(buffer)
})
}
fn parse_xmatrix_field<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<(String, Vec<u8>)> {
tokens.optional(|t| {
let name = parse_token(t).and_then(|name| {
let name = std::str::from_utf8(&name).ok()?.to_ascii_lowercase();
match name.as_str() {
"origin" | "destination" | "key" | "sig" => Some(name),
name => {
debug!(
"Encountered an invalid field name {} at location {}",
name,
t.location().offset()
);
None
}
}
})?;
let Some(xmatrix) = xmatrix else {
return Err(XMatrixParseError::NotFound);
};
if !t.token(&b'=') {
return None;
}
let value = parse_quoted(t).or_else(|| parse_token_with_colons(t))?;
Some((name, value))
})
}
fn parse_xmatrix<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<XMatrix> {
tokens.optional(|t| {
if !t.tokens(b"X-Matrix ") {
debug!("Failed to parse X-Matrix credentials, didn't start with 'X-Matrix '");
return None;
}
let mut origin = None;
let mut destination = None;
let mut key = None;
let mut sig = None;
for (name, value) in t.sep_by(|t| parse_xmatrix_field(t), |t| t.token(&b',')).as_iter() {
match name.as_str() {
"origin" => {
if origin.is_some() {
debug!("Field origin duplicated in X-Matrix Authorization header");
}
origin = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?);
for (name, value) in xmatrix.params {
if name.eq_ignore_ascii_case("origin") {
if origin.is_some() {
return Err(XMatrixParseError::DuplicateParameter("origin".to_owned()));
} else {
origin = Some(OwnedServerName::try_from(value.to_unescaped())?);
}
"destination" => {
if destination.is_some() {
debug!("Field destination duplicated in X-Matrix Authorization header");
}
destination = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?);
} else if name.eq_ignore_ascii_case("destination") {
if destination.is_some() {
return Err(XMatrixParseError::DuplicateParameter("destination".to_owned()));
} else {
destination = Some(OwnedServerName::try_from(value.to_unescaped())?);
}
"key" => {
if key.is_some() {
debug!("Field key duplicated in X-Matrix Authorization header");
}
key = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?);
} else if name.eq_ignore_ascii_case("key") {
if key.is_some() {
return Err(XMatrixParseError::DuplicateParameter("key".to_owned()));
} else {
key = Some(OwnedServerSigningKeyId::try_from(value.to_unescaped())?);
}
"sig" => {
if sig.is_some() {
debug!("Field sig duplicated in X-Matrix Authorization header");
}
sig = Some(std::str::from_utf8(&value).ok()?.to_owned());
}
name => {
debug!("Unknown field {} found in X-Matrix Authorization header", name);
} else if name.eq_ignore_ascii_case("sig") {
if sig.is_some() {
return Err(XMatrixParseError::DuplicateParameter("sig".to_owned()));
} else {
sig = Some(Base64::parse(value.to_unescaped())?);
}
} else {
debug!("Unknown parameter {name} in X-Matrix Authorization header");
}
}
Some(XMatrix { origin: origin?, destination, key: key?, sig: sig? })
})
Ok(Self {
origin: origin
.ok_or_else(|| XMatrixParseError::MissingParameter("origin".to_owned()))?,
destination,
key: key.ok_or_else(|| XMatrixParseError::MissingParameter("key".to_owned()))?,
sig: sig.ok_or_else(|| XMatrixParseError::MissingParameter("sig".to_owned()))?,
})
}
}
fn is_alpha(c: u8) -> bool {
(0x41..=0x5A).contains(&c) || (0x61..=0x7A).contains(&c)
impl fmt::Debug for XMatrix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("XMatrix")
.field("origin", &self.origin)
.field("destination", &self.destination)
.field("key", &self.key)
.finish_non_exhaustive()
}
}
fn is_digit(c: u8) -> bool {
(0x30..=0x39).contains(&c)
/// Whether the given char is a [token char].
///
/// [token char]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2
fn is_tchar(c: char) -> bool {
const TOKEN_CHARS: [char; 15] =
['!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'];
c.is_ascii_alphanumeric() || TOKEN_CHARS.contains(&c)
}
fn is_tchar(c: u8) -> bool {
const TOKEN_CHARS: [u8; 15] =
[b'!', b'#', b'$', b'%', b'&', b'\'', b'*', b'+', b'-', b'.', b'^', b'_', b'`', b'|', b'~'];
is_alpha(c) || is_digit(c) || TOKEN_CHARS.contains(&c)
/// If the field value does not contain only token chars, convert it to a [quoted string].
///
/// [quoted string]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4
fn escape_field_value(value: &str) -> Cow<'_, str> {
if !value.is_empty() && value.chars().all(is_tchar) {
return Cow::Borrowed(value);
}
let value = value.replace('\\', r#"\\"#).replace('"', r#"\""#);
Cow::Owned(format!("\"{value}\""))
}
fn is_qdtext(c: u8) -> bool {
c == b'\t'
|| c == b' '
|| c == 0x21
|| (0x23..=0x5B).contains(&c)
|| (0x5D..=0x7E).contains(&c)
|| is_obs_text(c)
impl fmt::Display for XMatrix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self { origin, destination, key, sig } = self;
let origin = escape_field_value(origin.as_str());
let key = escape_field_value(key.as_str());
let sig = sig.encode();
let sig = escape_field_value(&sig);
write!(f, r#"{} "#, Self::SCHEME)?;
if let Some(destination) = destination {
let destination = escape_field_value(destination.as_str());
write!(f, r#"destination={destination},"#)?;
}
write!(f, "key={key},origin={origin},sig={sig}")
}
}
fn is_obs_text(c: u8) -> bool {
c >= 0x80 // The spec does contain an upper limit of 0xFF here, but that's enforced by the type
impl FromStr for XMatrix {
type Err = XMatrixParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
fn is_vchar(c: u8) -> bool {
(0x21..=0x7E).contains(&c)
impl TryFrom<&HeaderValue> for XMatrix {
type Error = XMatrixParseError;
fn try_from(value: &HeaderValue) -> Result<Self, Self::Error> {
Self::parse(value.to_str()?)
}
}
fn is_quoted_pair(c: u8) -> bool {
c == b'\t' || c == b' ' || is_vchar(c) || is_obs_text(c)
impl From<&XMatrix> for HeaderValue {
fn from(value: &XMatrix) -> Self {
value.to_string().try_into().expect("header format is static")
}
}
impl Credentials for XMatrix {
const SCHEME: &'static str = "X-Matrix";
fn decode(value: &HeaderValue) -> Option<Self> {
let value: Vec<u8> = value.as_bytes().to_vec();
parse_xmatrix(&mut value.into_tokens())
value.try_into().ok()
}
fn encode(&self) -> HeaderValue {
if let Some(destination) = &self.destination {
format!(
"X-Matrix origin=\"{}\",destination=\"{destination}\",key=\"{}\",sig=\"{}\"",
self.origin, self.key, self.sig
)
} else {
format!("X-Matrix origin=\"{}\",key=\"{}\",sig=\"{}\"", self.origin, self.key, self.sig)
}
.try_into()
.expect("header format is static")
self.into()
}
}
/// An error when trying to parse an X-Matrix Authorization header.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum XMatrixParseError {
/// The `HeaderValue` could not be converted to a `str`.
#[error(transparent)]
ToStr(#[from] http::header::ToStrError),
/// The string could not be parsed as a valid Authorization string.
#[error("{0}")]
ParseStr(String),
/// The credentials with the X-Matrix scheme were not found.
#[error("X-Matrix credentials not found")]
NotFound,
/// The parameter value could not be parsed as a Matrix ID.
#[error(transparent)]
ParseId(#[from] IdParseError),
/// The parameter value could not be parsed as base64.
#[error(transparent)]
ParseBase64(#[from] Base64DecodeError),
/// The parameter with the given name was not found.
#[error("missing parameter '{0}'")]
MissingParameter(String),
/// The parameter with the given name was found more than once.
#[error("duplicate parameter '{0}'")]
DuplicateParameter(String),
}
impl<'a> From<http_auth::parser::Error<'a>> for XMatrixParseError {
fn from(value: http_auth::parser::Error<'a>) -> Self {
Self::ParseStr(value.to_string())
}
}
#[cfg(test)]
mod tests {
use headers::{authorization::Credentials, HeaderValue};
use ruma_common::OwnedServerName;
use ruma_common::{serde::Base64, OwnedServerName};
use super::XMatrix;
#[test]
fn xmatrix_auth_pre_1_3() {
let header = HeaderValue::from_static(
"X-Matrix origin=\"origin.hs.example.com\",key=\"ed25519:key1\",sig=\"ABCDEF...\"",
"X-Matrix origin=\"origin.hs.example.com\",key=\"ed25519:key1\",sig=\"dGVzdA==\"",
);
let origin = "origin.hs.example.com".try_into().unwrap();
let key = "ed25519:key1".try_into().unwrap();
let sig = "ABCDEF...".to_owned();
let credentials: XMatrix = Credentials::decode(&header).unwrap();
let sig = Base64::new(b"test".to_vec());
let credentials = XMatrix::try_from(&header).unwrap();
assert_eq!(credentials.origin, origin);
assert_eq!(credentials.destination, None);
assert_eq!(credentials.key, key);
@ -261,17 +256,20 @@ mod tests {
let credentials = XMatrix { origin, destination: None, key, sig };
assert_eq!(credentials.encode(), header);
assert_eq!(
credentials.encode(),
"X-Matrix key=\"ed25519:key1\",origin=origin.hs.example.com,sig=dGVzdA"
);
}
#[test]
fn xmatrix_auth_1_3() {
let header = HeaderValue::from_static("X-Matrix origin=\"origin.hs.example.com\",destination=\"destination.hs.example.com\",key=\"ed25519:key1\",sig=\"ABCDEF...\"");
let header = HeaderValue::from_static("X-Matrix origin=\"origin.hs.example.com\",destination=\"destination.hs.example.com\",key=\"ed25519:key1\",sig=\"dGVzdA==\"");
let origin: OwnedServerName = "origin.hs.example.com".try_into().unwrap();
let destination: OwnedServerName = "destination.hs.example.com".try_into().unwrap();
let key = "ed25519:key1".try_into().unwrap();
let sig = "ABCDEF...".to_owned();
let credentials: XMatrix = Credentials::decode(&header).unwrap();
let sig = Base64::new(b"test".to_vec());
let credentials = XMatrix::try_from(&header).unwrap();
assert_eq!(credentials.origin, origin);
assert_eq!(credentials.destination, Some(destination.clone()));
assert_eq!(credentials.key, key);
@ -279,6 +277,41 @@ mod tests {
let credentials = XMatrix::new(origin, destination, key, sig);
assert_eq!(credentials.encode(), header);
assert_eq!(credentials.encode(), "X-Matrix destination=destination.hs.example.com,key=\"ed25519:key1\",origin=origin.hs.example.com,sig=dGVzdA");
}
#[test]
fn xmatrix_quoting() {
let header = HeaderValue::from_static(
r#"X-Matrix origin="example.com:1234",key="abc\"def\\:ghi",sig=dGVzdA,"#,
);
let origin: OwnedServerName = "example.com:1234".try_into().unwrap();
let key = r#"abc"def\:ghi"#.try_into().unwrap();
let sig = Base64::new(b"test".to_vec());
let credentials = XMatrix::try_from(&header).unwrap();
assert_eq!(credentials.origin, origin);
assert_eq!(credentials.destination, None);
assert_eq!(credentials.key, key);
assert_eq!(credentials.sig, sig);
let credentials = XMatrix { origin, destination: None, key, sig };
assert_eq!(
credentials.encode(),
r#"X-Matrix key="abc\"def\\:ghi",origin="example.com:1234",sig=dGVzdA"#
);
}
#[test]
fn xmatrix_auth_1_3_with_extra_spaces() {
let header = HeaderValue::from_static("X-Matrix origin=\"origin.hs.example.com\" , destination=\"destination.hs.example.com\",key=\"ed25519:key1\", sig=\"dGVzdA\"");
let credentials = XMatrix::try_from(&header).unwrap();
let sig = Base64::new(b"test".to_vec());
assert_eq!(credentials.origin, "origin.hs.example.com");
assert_eq!(credentials.destination.unwrap(), "destination.hs.example.com");
assert_eq!(credentials.key, "ed25519:key1");
assert_eq!(credentials.sig, sig);
}
}

View File

@ -226,7 +226,6 @@ unstable-msc2448 = [
]
unstable-msc2654 = ["ruma-client-api?/unstable-msc2654"]
unstable-msc2666 = ["ruma-client-api?/unstable-msc2666"]
unstable-msc2705 = ["ruma-client-api?/unstable-msc2705"]
unstable-msc2747 = ["ruma-events?/unstable-msc2747"]
unstable-msc2867 = ["ruma-events?/unstable-msc2867"]
unstable-msc2870 = ["ruma-common/unstable-msc2870"]
@ -242,10 +241,10 @@ unstable-msc3245 = ["ruma-events?/unstable-msc3245"]
unstable-msc3245-v1-compat = ["ruma-events?/unstable-msc3245-v1-compat"]
unstable-msc3246 = ["ruma-events?/unstable-msc3246"]
unstable-msc3266 = ["ruma-client-api?/unstable-msc3266"]
unstable-msc3291 = ["ruma-events?/unstable-msc3291"]
unstable-msc3381 = ["ruma-events?/unstable-msc3381"]
unstable-msc3401 = ["ruma-events?/unstable-msc3401"]
unstable-msc3488 = ["ruma-client-api?/unstable-msc3488", "ruma-events?/unstable-msc3488"]
unstable-msc3489 = ["ruma-events?/unstable-msc3489"]
unstable-msc3551 = ["ruma-events?/unstable-msc3551"]
unstable-msc3552 = ["ruma-events?/unstable-msc3552"]
unstable-msc3553 = ["ruma-events?/unstable-msc3553"]
@ -255,7 +254,6 @@ unstable-msc3618 = ["ruma-federation-api?/unstable-msc3618"]
unstable-msc3723 = ["ruma-federation-api?/unstable-msc3723"]
unstable-msc3814 = ["ruma-client-api?/unstable-msc3814"]
unstable-msc3843 = ["ruma-client-api?/unstable-msc3843", "ruma-federation-api?/unstable-msc3843"]
unstable-msc3916 = ["ruma-client-api?/unstable-msc3916"]
unstable-msc3927 = ["ruma-events?/unstable-msc3927"]
unstable-msc3930 = ["ruma-common/unstable-msc3930"]
unstable-msc3931 = ["ruma-common/unstable-msc3931"]
@ -268,6 +266,7 @@ unstable-msc4075 = ["ruma-events?/unstable-msc4075"]
unstable-msc4108 = ["ruma-client-api?/unstable-msc4108"]
unstable-msc4121 = ["ruma-client-api?/unstable-msc4121"]
unstable-msc4125 = ["ruma-federation-api?/unstable-msc4125"]
unstable-msc4140 = ["ruma-client-api?/unstable-msc4140"]
unstable-pdu = ["ruma-events?/unstable-pdu"]
unstable-unspecified = [
"ruma-common/unstable-unspecified",
@ -285,7 +284,6 @@ __ci = [
"unstable-msc2448",
"unstable-msc2654",
"unstable-msc2666",
"unstable-msc2705",
"unstable-msc2747",
"unstable-msc2867",
"unstable-msc2870",
@ -297,10 +295,10 @@ __ci = [
"unstable-msc3245-v1-compat",
"unstable-msc3246",
"unstable-msc3266",
"unstable-msc3291",
"unstable-msc3381",
"unstable-msc3401",
"unstable-msc3488",
"unstable-msc3489",
"unstable-msc3551",
"unstable-msc3552",
"unstable-msc3553",
@ -310,7 +308,6 @@ __ci = [
"unstable-msc3723",
"unstable-msc3814",
"unstable-msc3843",
"unstable-msc3916",
"unstable-msc3927",
"unstable-msc3930",
"unstable-msc3931",
@ -323,6 +320,7 @@ __ci = [
"unstable-msc4108",
"unstable-msc4121",
"unstable-msc4125",
"unstable-msc4140"
]
[dependencies]

View File

@ -17,7 +17,7 @@ const OLD_URL_WHITELIST: &[&str] =
/// Authorized versions in URLs pointing to the new specs.
const NEW_VERSION_WHITELIST: &[&str] = &[
"v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6", "v1.7", "v1.8", "v1.9", "v1.10",
"v1.1", "v1.2", "v1.3", "v1.4", "v1.5", "v1.6", "v1.7", "v1.8", "v1.9", "v1.10", "v1.11",
"latest",
// This should only be enabled if a legitimate use case is found.
// "unstable",
@ -220,48 +220,72 @@ fn get_page_ids(url: &str) -> Result<HashMap<String, HasDuplicates>> {
continue;
};
// For the URLs using the "latest" version, log the actual version we got.
if url[URL_PREFIX.len()..].starts_with("latest/") {
// Let's use the `meta` element with the `og:url` property, it contains the original
// relative URL of the page.
if tag.name.0 == b"meta"
&& tag
.attributes
.get(b"property".as_slice())
.is_some_and(|value| value.0 == b"og:url")
{
match tag.attributes.get(b"content".as_slice()) {
Some(value) => {
println!(
"Original URL for latest spec page: {}",
String::from_utf8_lossy(value)
);
}
None => println!(
"Could not get original URL for latest spec page: /{}",
&url[URL_PREFIX.len()..]
),
}
}
}
let Some(id) =
tag.attributes.get(b"id".as_slice()).and_then(|s| String::from_utf8(s.0.clone()).ok())
else {
continue;
};
let (id, has_duplicates) = uniquify_heading_id(id, &mut ids);
let has_duplicates = heading_id_has_duplicates(&id, &mut ids);
ids.insert(id, has_duplicates);
}
Ok(ids)
}
/// Make sure the ID is unique in the page, if not make it unique.
/// Check whether the given heading ID has duplicates in the given map.
///
/// This is necessary because Matrix spec pages do that in JavaScript, so IDs
/// are not unique in the source.
///
/// This is a reimplementation of the algorithm used for the spec.
///
/// See <https://github.com/matrix-org/matrix-spec/blob/6b02e393082570db2d0a651ddb79a365bc4a0f8d/static/js/toc.js#L25-L37>.
fn uniquify_heading_id(
mut id: String,
/// This check is necessary because duplicates IDs have a number depending on their occurrence in a
/// HTML page. If a duplicate ID is added, moved or removed from the spec, its number might change
/// from one version to the next.
fn heading_id_has_duplicates(
id: &str,
unique_ids: &mut HashMap<String, HasDuplicates>,
) -> (String, HasDuplicates) {
let base_id = id.clone();
let mut counter: u16 = 0;
let mut has_duplicates = HasDuplicates::No;
) -> HasDuplicates {
// IDs that should be duplicates end with `-{number}`.
let Some((start, _end)) =
id.rsplit_once('-').filter(|(_start, end)| end.chars().all(|c| c.is_ascii_digit()))
else {
return HasDuplicates::No;
};
while let Some(other_id_has_dup) = unique_ids.get_mut(&id) {
has_duplicates = HasDuplicates::Yes;
// Update the first duplicate ID, because it doesn't end with a number.
if let Some(other_id_has_dup) = unique_ids.get_mut(start) {
*other_id_has_dup = HasDuplicates::Yes;
counter += 1;
id = format!("{base_id}-{counter}");
}
(id, has_duplicates)
HasDuplicates::Yes
}
fn print_link_err(error: &str, link: &SpecLink) {
println!(
"\n{error}\nfile: {}:{}\nlink: {}",
"\n{error}\n file: {}:{}\n link: {}",
link.path.display(),
link.line,
link.url.get(..80).unwrap_or(&link.url),