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 ### 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: Write commit messages using the imperative mood, as if completing the sentence:
"If applied, this commit will \_\_\_." For example, use "Fix some bug" instead "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". 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. /// 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). // 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>, 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. /// 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). // 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>, pub fields: BTreeMap<String, String>,
} }

View File

@ -6,6 +6,7 @@ Breaking changes:
as before. as before.
- Change type of `client_secret` field in `ThirdpartyIdCredentials` - Change type of `client_secret` field in `ThirdpartyIdCredentials`
from `Box<ClientSecret>` to `OwnedClientSecret` from `Box<ClientSecret>` to `OwnedClientSecret`
- Make `id_server` and `id_access_token` in `ThirdpartyIdCredentials` optional
Improvements: Improvements:
@ -13,15 +14,23 @@ Improvements:
- Heroes in `sync::sync_events::v4`: `SyncRequestList` and `RoomSubscription` - Heroes in `sync::sync_events::v4`: `SyncRequestList` and `RoomSubscription`
both have a new `include_heroes` field. `SlidingSyncRoom` has a new `heroes` both have a new `include_heroes` field. `SlidingSyncRoom` has a new `heroes`
field, with a new type `SlidingSyncRoomHero`. field, with a new type `SlidingSyncRoomHero`.
- Add unstable support for authenticated media endpoints, according to MSC3916. - Add support for authenticated media endpoints, according to MSC3916 / Matrix
1.11.
Bug fixes: - They replace the newly deprecated `media::get_*` endpoints.
- Stabilize support for animated thumbnails, according to Matrix 1.11
- Rename `avatar` to `avatar_url` when (De)serializing - 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: Bug fixes:
- Rename `avatar` to `avatar_url` when (De)serializing `SlidingSyncRoomHero`
- `user_id` of `SlidingSyncRoomHero` is now mandatory - `user_id` of `SlidingSyncRoomHero` is now mandatory
- Make authentication with access token optional for the `change_password` and
`deactivate` endpoints.
# 0.18.0 # 0.18.0

View File

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

View File

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

View File

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

View File

@ -2,10 +2,10 @@
//! //!
//! Retrieve content from the media store. //! Retrieve content from the media store.
pub mod unstable { pub mod v1 {
//! `/unstable/org.matrix.msc3916/` ([MSC]) //! `/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; use std::time::Duration;
@ -17,10 +17,11 @@ pub mod unstable {
const METADATA: Metadata = metadata! { const METADATA: Metadata = metadata! {
method: GET, method: GET,
rate_limited: false, rate_limited: true,
authentication: AccessToken, authentication: AccessToken,
history: { history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/download/:server_name/:media_id", 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. //! Retrieve content from the media store, specifying a filename to return.
pub mod unstable { pub mod v1 {
//! `/unstable/org.matrix.msc3916/` ([MSC]) //! `/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; use std::time::Duration;
@ -17,10 +17,11 @@ pub mod unstable {
const METADATA: Metadata = metadata! { const METADATA: Metadata = metadata! {
method: GET, method: GET,
rate_limited: false, rate_limited: true,
authentication: AccessToken, authentication: AccessToken,
history: { history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/download/:server_name/:media_id/:filename", 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. //! Get a thumbnail of content from the media store.
pub mod unstable { pub mod v1 {
//! `/unstable/org.matrix.msc3916/` ([MSC]) //! `/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; use std::time::Duration;
@ -24,6 +24,7 @@ pub mod unstable {
authentication: AccessToken, authentication: AccessToken,
history: { history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/thumbnail/:server_name/:media_id", 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. /// Whether the server should return an animated thumbnail.
/// ///
/// When `true`, the server should return an animated thumbnail if possible and supported. /// When `Some(true)`, the server should return an animated thumbnail if possible and
/// Otherwise it must not return an animated thumbnail. /// supported. When `Some(false)`, the server must not return an animated
/// /// thumbnail. When `None`, the server should not return an animated thumbnail.
/// Defaults to `false`.
#[cfg(feature = "unstable-msc2705")]
#[ruma_api(query)] #[ruma_api(query)]
#[serde( #[serde(skip_serializing_if = "Option::is_none")]
rename = "org.matrix.msc2705.animated", pub animated: Option<bool>,
default,
skip_serializing_if = "ruma_common::serde::is_default"
)]
pub animated: bool,
} }
/// Response type for the `get_content_thumbnail` endpoint. /// Response type for the `get_content_thumbnail` endpoint.
@ -111,8 +106,7 @@ pub mod unstable {
width, width,
height, height,
timeout_ms: crate::media::default_download_timeout(), timeout_ms: crate::media::default_download_timeout(),
#[cfg(feature = "unstable-msc2705")] animated: None,
animated: false,
} }
} }

View File

@ -2,10 +2,10 @@
//! //!
//! Gets the config for the media repository. //! Gets the config for the media repository.
pub mod unstable { pub mod v1 {
//! `/unstable/org.matrix.msc3916/` ([MSC]) //! `/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 js_int::UInt;
use ruma_common::{ use ruma_common::{
@ -19,6 +19,7 @@ pub mod unstable {
authentication: AccessToken, authentication: AccessToken,
history: { history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/config", 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. //! Get a preview for a URL.
pub mod unstable { pub mod v1 {
//! `/unstable/org.matrix.msc3916/` ([MSC]) //! `/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::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
@ -20,6 +20,7 @@ pub mod unstable {
authentication: AccessToken, authentication: AccessToken,
history: { history: {
unstable => "/_matrix/client/unstable/org.matrix.msc3916/media/preview_url", 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 account;
pub mod alias; pub mod alias;
pub mod appservice; pub mod appservice;
#[cfg(feature = "unstable-msc3916")]
pub mod authenticated_media; pub mod authenticated_media;
pub mod backup; pub mod backup;
pub mod config; pub mod config;
@ -24,6 +23,8 @@ pub mod directory;
pub mod discovery; pub mod discovery;
pub mod error; pub mod error;
pub mod filter; pub mod filter;
#[cfg(feature = "unstable-msc4140")]
pub mod future;
pub mod http_headers; pub mod http_headers;
pub mod keys; pub mod keys;
pub mod knock; pub mod knock;

View File

@ -24,11 +24,16 @@ pub mod v3 {
history: { history: {
1.0 => "/_matrix/media/r0/download/:server_name/:media_id", 1.0 => "/_matrix/media/r0/download/:server_name/:media_id",
1.1 => "/_matrix/media/v3/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 type for the `get_media_content` endpoint.
#[request(error = crate::Error)] #[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 { pub struct Request {
/// The server name from the mxc:// URI (the authoritory component). /// The server name from the mxc:// URI (the authoritory component).
#[ruma_api(path)] #[ruma_api(path)]
@ -106,6 +111,7 @@ pub mod v3 {
pub cache_control: Option<String>, pub cache_control: Option<String>,
} }
#[allow(deprecated)]
impl Request { impl Request {
/// Creates a new `Request` with the given media ID and server name. /// Creates a new `Request` with the given media ID and server name.
pub fn new(media_id: String, server_name: OwnedServerName) -> Self { pub fn new(media_id: String, server_name: OwnedServerName) -> Self {

View File

@ -24,11 +24,16 @@ pub mod v3 {
history: { history: {
1.0 => "/_matrix/media/r0/download/:server_name/:media_id/:filename", 1.0 => "/_matrix/media/r0/download/:server_name/:media_id/:filename",
1.1 => "/_matrix/media/v3/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 type for the `get_media_content_as_filename` endpoint.
#[request(error = crate::Error)] #[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 { pub struct Request {
/// The server name from the mxc:// URI (the authoritory component). /// The server name from the mxc:// URI (the authoritory component).
#[ruma_api(path)] #[ruma_api(path)]
@ -110,6 +115,7 @@ pub mod v3 {
pub cache_control: Option<String>, pub cache_control: Option<String>,
} }
#[allow(deprecated)]
impl Request { impl Request {
/// Creates a new `Request` with the given media ID, server name and filename. /// 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 { pub fn new(media_id: String, server_name: OwnedServerName, filename: String) -> Self {

View File

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

View File

@ -20,12 +20,17 @@ pub mod v3 {
history: { history: {
1.0 => "/_matrix/media/r0/config", 1.0 => "/_matrix/media/r0/config",
1.1 => "/_matrix/media/v3/config", 1.1 => "/_matrix/media/v3/config",
1.11 => deprecated,
} }
}; };
/// Request type for the `get_media_config` endpoint. /// Request type for the `get_media_config` endpoint.
#[request(error = crate::Error)] #[request(error = crate::Error)]
#[derive(Default)] #[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 {} pub struct Request {}
/// Response type for the `get_media_config` endpoint. /// Response type for the `get_media_config` endpoint.
@ -36,6 +41,7 @@ pub mod v3 {
pub upload_size: UInt, pub upload_size: UInt,
} }
#[allow(deprecated)]
impl Request { impl Request {
/// Creates an empty `Request`. /// Creates an empty `Request`.
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -21,11 +21,16 @@ pub mod v3 {
history: { history: {
1.0 => "/_matrix/media/r0/preview_url", 1.0 => "/_matrix/media/r0/preview_url",
1.1 => "/_matrix/media/v3/preview_url", 1.1 => "/_matrix/media/v3/preview_url",
1.11 => deprecated,
} }
}; };
/// Request type for the `get_media_preview` endpoint. /// Request type for the `get_media_preview` endpoint.
#[request(error = crate::Error)] #[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 { pub struct Request {
/// URL to get a preview of. /// URL to get a preview of.
#[ruma_api(query)] #[ruma_api(query)]
@ -49,6 +54,7 @@ pub mod v3 {
pub data: Option<Box<RawJsonValue>>, pub data: Option<Box<RawJsonValue>>,
} }
#[allow(deprecated)]
impl Request { impl Request {
/// Creates a new `Request` with the given url. /// Creates a new `Request` with the given url.
pub fn new(url: String) -> Self { 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 Matrix identifier][spec-mxid], and one to invite a user
//! [by their third party identifier][spec-3pid]. //! [by their third party identifier][spec-3pid].
//! //!
//! [spec-mxid]: https://spec.matrix.org/v1.10/client-server-api/#post_matrixclientv3roomsroomidinvite //! [spec-mxid]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3roomsroomidinvite
//! [spec-3pid]: https://spec.matrix.org/v1.10/client-server-api/#post_matrixclientv3roomsroomidinvite-1 //! [spec-3pid]: https://spec.matrix.org/latest/client-server-api/#thirdparty_post_matrixclientv3roomsroomidinvite
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, 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. /// 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). // 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>, 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. /// 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). // 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>, pub fields: BTreeMap<String, String>,
} }

View File

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

View File

@ -1,9 +1,30 @@
# [unreleased] # [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: Improvements:
- Add the `InvalidHeaderValue` variant to the `DeserializationError` struct, for - Add the `InvalidHeaderValue` variant to the `DeserializationError` struct, for
cases where we receive a HTTP header with an unexpected value. 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 # 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. /// 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 /// * `#[ruma_api(query)]`: Fields with this attribute will be inserting into the URL's query
/// string. /// string.
/// * `#[ruma_api(query_map)]`: Instead of individual query fields, one query_map field, of any /// * `#[ruma_api(query_all)]`: Instead of individual query fields, one query_all field, of any
/// type that implements `IntoIterator<Item = (String, String)>` (e.g. `HashMap<String, /// type that can be (de)serialized by [serde_html_form], can be used for cases where
/// String>`, can be used for cases where an endpoint supports arbitrary query parameters. /// 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]` /// * No attribute: Fields without an attribute are part of the body. They can use `#[serde]`
/// attributes to customize (de)serialization. /// attributes to customize (de)serialization.
/// * `#[ruma_api(body)]`: Use this if multiple endpoints should share a request body type, or /// * `#[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 {} /// # pub struct Response {}
/// } /// }
/// ``` /// ```
///
/// [serde_html_form]: https://crates.io/crates/serde_html_form
pub use ruma_macros::request; pub use ruma_macros::request;
/// Generates [`OutgoingResponse`] and [`IncomingResponse`] implementations. /// Generates [`OutgoingResponse`] and [`IncomingResponse`] implementations.
/// ///
/// The `OutgoingRequest` impl is feature-gated behind `cfg(feature = "client")`. /// The `OutgoingResponse` impl is feature-gated behind `cfg(feature = "server")`.
/// The `IncomingRequest` 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. /// The generated code expects a `METADATA` constant of type [`Metadata`] to be in scope.
/// ///
@ -223,7 +227,7 @@ pub use ruma_macros::request;
/// ///
/// ## Attributes /// ## 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 /// * `#[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 /// 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 /// Authentication is performed by including an access token in the `Authentication` http
/// header, or an `access_token` query parameter. /// 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, AccessToken,
/// Authentication is optional, and it is performed by including an access token in the /// Authentication is optional, and it is performed by including an access token in the
/// `Authentication` http header, or an `access_token` query parameter. /// `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, AccessTokenOptional,
/// Authentication is only performed for appservices, by including an access token in the /// Authentication is only performed for appservices, by including an access token in the
/// `Authentication` http header, or an `access_token` query parameter. /// `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, AppserviceToken,
/// Authentication is performed by including X-Matrix signatures in the request headers, /// 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/>. /// See <https://spec.matrix.org/v1.10/>.
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 { impl TryFrom<&str> for MatrixVersion {
@ -564,6 +569,7 @@ impl TryFrom<&str> for MatrixVersion {
"v1.8" => V1_8, "v1.8" => V1_8,
"v1.9" => V1_9, "v1.9" => V1_9,
"v1.10" => V1_10, "v1.10" => V1_10,
"v1.11" => V1_11,
_ => return Err(UnknownVersionError), _ => return Err(UnknownVersionError),
}) })
} }
@ -613,6 +619,7 @@ impl MatrixVersion {
MatrixVersion::V1_8 => (1, 8), MatrixVersion::V1_8 => (1, 8),
MatrixVersion::V1_9 => (1, 9), MatrixVersion::V1_9 => (1, 9),
MatrixVersion::V1_10 => (1, 10), MatrixVersion::V1_10 => (1, 10),
MatrixVersion::V1_11 => (1, 11),
} }
} }
@ -630,6 +637,7 @@ impl MatrixVersion {
(1, 8) => Ok(MatrixVersion::V1_8), (1, 8) => Ok(MatrixVersion::V1_8),
(1, 9) => Ok(MatrixVersion::V1_9), (1, 9) => Ok(MatrixVersion::V1_9),
(1, 10) => Ok(MatrixVersion::V1_10), (1, 10) => Ok(MatrixVersion::V1_10),
(1, 11) => Ok(MatrixVersion::V1_11),
_ => Err(UnknownVersionError), _ => Err(UnknownVersionError),
} }
} }
@ -724,7 +732,9 @@ impl MatrixVersion {
// <https://spec.matrix.org/v1.9/rooms/#complete-list-of-room-versions> // <https://spec.matrix.org/v1.9/rooms/#complete-list-of-room-versions>
| MatrixVersion::V1_9 | MatrixVersion::V1_9
// <https://spec.matrix.org/v1.10/rooms/#complete-list-of-room-versions> // <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), User(OwnedUserId),
/// An event ID. /// 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), Event(OwnedRoomOrAliasId, OwnedEventId),
} }
@ -572,12 +575,11 @@ mod tests {
.to_string(), .to_string(),
"https://matrix.to/#/!ruma:notareal.hs?via=notareal.hs" "https://matrix.to/#/!ruma:notareal.hs?via=notareal.hs"
); );
assert_eq!( #[allow(deprecated)]
room_alias_id!("#ruma:notareal.hs") let uri = room_alias_id!("#ruma:notareal.hs")
.matrix_to_event_uri(event_id!("$event:notareal.hs")) .matrix_to_event_uri(event_id!("$event:notareal.hs"))
.to_string(), .to_string();
"https://matrix.to/#/%23ruma:notareal.hs/$event:notareal.hs" assert_eq!(uri, "https://matrix.to/#/%23ruma:notareal.hs/$event:notareal.hs");
);
assert_eq!( assert_eq!(
room_id!("!ruma:notareal.hs") room_id!("!ruma:notareal.hs")
.matrix_to_event_uri(event_id!("$event:notareal.hs")) .matrix_to_event_uri(event_id!("$event:notareal.hs"))
@ -869,12 +871,11 @@ mod tests {
.to_string(), .to_string(),
"matrix:roomid/ruma:notareal.hs?via=notareal.hs&via=anotherunreal.hs&action=join" "matrix:roomid/ruma:notareal.hs?via=notareal.hs&via=anotherunreal.hs&action=join"
); );
assert_eq!( #[allow(deprecated)]
room_alias_id!("#ruma:notareal.hs") let uri = room_alias_id!("#ruma:notareal.hs")
.matrix_event_uri(event_id!("$event:notareal.hs")) .matrix_event_uri(event_id!("$event:notareal.hs"))
.to_string(), .to_string();
"matrix:r/ruma:notareal.hs/e/event:notareal.hs" assert_eq!(uri, "matrix:r/ruma:notareal.hs/e/event:notareal.hs");
);
assert_eq!( assert_eq!(
room_id!("!ruma:notareal.hs") room_id!("!ruma:notareal.hs")
.matrix_event_uri(event_id!("$event: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. /// 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 { pub fn matrix_to_event_uri(&self, ev_id: impl Into<OwnedEventId>) -> MatrixToUri {
MatrixToUri::new((self.to_owned(), ev_id.into()).into(), Vec::new()) 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. /// 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 { pub fn matrix_event_uri(&self, ev_id: impl Into<OwnedEventId>) -> MatrixUri {
MatrixUri::new((self.to_owned(), ev_id.into()).into(), Vec::new(), None) 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!(!"m".matches_word("[[:alpha:]]?"));
assert!("[[:alpha:]]!".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!("An example event.".matches_word("ex*ple"));
assert!("exple".matches_word("ex*ple")); assert!("exple".matches_word("ex*ple"));
assert!("An exciting triple-whammy".matches_word("ex*ple")); assert!("An exciting triple-whammy".matches_word("ex*ple"));
@ -651,7 +651,7 @@ mod tests {
assert!("".matches_pattern("*", false)); assert!("".matches_pattern("*", false));
assert!(!"foo".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 plans".matches_pattern("lunc?*", false));
assert!("LUNCH".matches_pattern("lunc?*", false)); assert!("LUNCH".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 //! [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}; use serde::{Deserialize, Serialize};
@ -91,7 +94,8 @@ pub struct ProtocolInstance {
/// ///
/// See [matrix-spec#833](https://github.com/matrix-org/matrix-spec/issues/833). /// See [matrix-spec#833](https://github.com/matrix-org/matrix-spec/issues/833).
#[cfg(feature = "unstable-unspecified")] #[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`. /// Initial set of fields of `Protocol`.
@ -109,30 +113,18 @@ pub struct ProtocolInstanceInit {
/// A unique identifier across all instances. /// A unique identifier across all instances.
pub network_id: String, 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 { impl From<ProtocolInstanceInit> for ProtocolInstance {
fn from(init: ProtocolInstanceInit) -> Self { fn from(init: ProtocolInstanceInit) -> Self {
let ProtocolInstanceInit { let ProtocolInstanceInit { desc, fields, network_id } = init;
desc,
fields,
network_id,
#[cfg(feature = "unstable-unspecified")]
instance_id,
} = init;
Self { Self {
desc, desc,
icon: None, icon: None,
fields, fields,
network_id, network_id,
#[cfg(feature = "unstable-unspecified")] #[cfg(feature = "unstable-unspecified")]
instance_id, instance_id: None,
} }
} }
} }
@ -240,7 +232,6 @@ pub enum Medium {
/// this type using `ThirdPartyIdentifier::Init` / `.into()`. /// this type using `ThirdPartyIdentifier::Init` / `.into()`.
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[cfg_attr(test, derive(PartialEq))]
pub struct ThirdPartyIdentifier { pub struct ThirdPartyIdentifier {
/// The third party identifier address. /// The third party identifier address.
pub address: String, pub address: String,
@ -255,6 +246,20 @@ pub struct ThirdPartyIdentifier {
pub added_at: MilliSecondsSinceUnixEpoch, 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`. /// Initial set of fields of `ThirdPartyIdentifier`.
/// ///
/// This struct will not be updated even if additional fields are added to `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::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
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] #[request]
pub struct Request { pub struct Request {
#[ruma_api(query_map)] #[ruma_api(query_all)]
pub fields: Vec<(String, String)>, pub fields: Vec<(String, String)>,
} }
/// Response type for the `newtype_body_endpoint` endpoint. /// Response type for the `query_all_vec_endpoint` endpoint.
#[response] #[response]
pub struct Response {} pub struct Response {}
} }

View File

@ -1,12 +1,25 @@
# [unreleased] # [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: 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: 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 # 0.28.1

View File

@ -16,7 +16,7 @@ all-features = true
[features] [features]
canonical-json = ["ruma-common/canonical-json"] canonical-json = ["ruma-common/canonical-json"]
html = ["dep:ruma-html"] html = ["dep:ruma-html"]
markdown = ["pulldown-cmark"] markdown = ["dep:pulldown-cmark"]
unstable-exhaustive-types = [] unstable-exhaustive-types = []
unstable-msc1767 = [] unstable-msc1767 = []
unstable-msc2448 = [] 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 # https://github.com/matrix-org/matrix-spec-proposals/blob/83f6c5b469c1d78f714e335dcaa25354b255ffa5/proposals/3245-voice-messages.md
unstable-msc3245-v1-compat = [] unstable-msc3245-v1-compat = []
unstable-msc3246 = ["unstable-msc3927"] unstable-msc3246 = ["unstable-msc3927"]
unstable-msc3291 = []
unstable-msc3381 = ["unstable-msc1767"] unstable-msc3381 = ["unstable-msc1767"]
unstable-msc3401 = [] unstable-msc3401 = []
unstable-msc3488 = ["unstable-msc1767"] unstable-msc3488 = ["unstable-msc1767"]
unstable-msc3489 = ["unstable-msc3488"]
unstable-msc3551 = ["unstable-msc3956"] unstable-msc3551 = ["unstable-msc3956"]
unstable-msc3552 = ["unstable-msc3551"] unstable-msc3552 = ["unstable-msc3551"]
unstable-msc3553 = ["unstable-msc3552"] unstable-msc3553 = ["unstable-msc3552"]
@ -41,7 +41,7 @@ unstable-msc3927 = ["unstable-msc3551"]
unstable-msc3954 = ["unstable-msc1767"] unstable-msc3954 = ["unstable-msc1767"]
unstable-msc3955 = ["unstable-msc1767"] unstable-msc3955 = ["unstable-msc1767"]
unstable-msc3956 = ["unstable-msc1767"] unstable-msc3956 = ["unstable-msc1767"]
unstable-msc4075 = [] unstable-msc4075 = ["unstable-msc3401"]
unstable-pdu = [] unstable-pdu = []
# Allow some mandatory fields to be missing, defaulting them to an empty string # 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_int = { workspace = true, features = ["serde"] }
js_option = "0.1.0" js_option = "0.1.0"
percent-encoding = "2.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"] } regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] }
ruma-common = { workspace = true } ruma-common = { workspace = true }
ruma-html = { workspace = true, optional = 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")] #[cfg(feature = "unstable-msc4075")]
pub mod notify; pub mod notify;
pub mod reject; pub mod reject;
#[cfg(feature = "unstable-msc3291")]
pub mod sdp_stream_metadata_changed; pub mod sdp_stream_metadata_changed;
pub mod select_answer; pub mod select_answer;
@ -59,14 +58,12 @@ pub struct StreamMetadata {
/// Whether the audio track of the stream is muted. /// Whether the audio track of the stream is muted.
/// ///
/// Defaults to `false`. /// Defaults to `false`.
#[cfg(feature = "unstable-msc3291")]
#[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")]
pub audio_muted: bool, pub audio_muted: bool,
/// Whether the video track of the stream is muted. /// Whether the video track of the stream is muted.
/// ///
/// Defaults to `false`. /// Defaults to `false`.
#[cfg(feature = "unstable-msc3291")]
#[serde(default, skip_serializing_if = "ruma_common::serde::is_default")] #[serde(default, skip_serializing_if = "ruma_common::serde::is_default")]
pub video_muted: bool, pub video_muted: bool,
} }
@ -74,13 +71,7 @@ pub struct StreamMetadata {
impl StreamMetadata { impl StreamMetadata {
/// Creates a new `StreamMetadata` with the given purpose. /// Creates a new `StreamMetadata` with the given purpose.
pub fn new(purpose: StreamPurpose) -> Self { pub fn new(purpose: StreamPurpose) -> Self {
Self { Self { purpose, audio_muted: false, video_muted: false }
purpose,
#[cfg(feature = "unstable-msc3291")]
audio_muted: false,
#[cfg(feature = "unstable-msc3291")]
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. //! This implements a newer/updated version of MSC3401.
//! //!
//! [MSC3401]: https://github.com/matrix-org/matrix-spec-proposals/pull/3401 //! [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; pub use focus::*;
use ruma_common::{serde::StringEnum, MilliSecondsSinceUnixEpoch, OwnedUserId}; pub use member_data::*;
use ruma_macros::EventContent; use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId};
use ruma_macros::{EventContent, StringEnum};
use serde::{Deserialize, Serialize}; 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 /// This is the object containing all the data related to a Matrix users participation in a
/// matrixRTC session. It consists of memberships / sessions. /// MatrixRTC session.
#[derive(Clone, Debug, Serialize, Deserialize, EventContent)] ///
#[cfg_attr(test, derive(PartialEq))] /// 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)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = OwnedUserId)] #[serde(untagged)]
pub struct CallMemberEventContent { pub enum CallMemberEventContent {
/// A list of all the memberships that user currently has in this room. /// The legacy format for m.call.member events. (An array of memberships. The devices of one
/// /// user.)
/// There can be multiple ones in cases the user participates with multiple devices or there LegacyContent(LegacyMembershipContent),
/// are multiple RTC applications running. /// Normal membership events. One event per membership. Multiple state keys will
/// /// be used to describe multiple devices for one user.
/// e.g. a call and a spacial experience. SessionContent(SessionMembershipData),
/// /// An empty content means this user has been in a rtc session but is not anymore.
/// Important: This includes expired memberships. Empty(EmptyMembershipData),
/// To retrieve a list including only valid memberships,
/// see [`active_memberships`](CallMemberEventContent::active_memberships).
pub memberships: Vec<Membership>,
} }
impl CallMemberEventContent { impl CallMemberEventContent {
/// Creates a new `CallMemberEventContent`. /// Creates a new [`CallMemberEventContent`] with [`LegacyMembershipData`].
pub fn new(memberships: Vec<Membership>) -> Self { pub fn new_legacy(memberships: Vec<LegacyMembershipData>) -> Self {
Self { memberships } 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. /// 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. /// The memberships field will also include expired events.
/// ///
/// This copies all the memberships and converts them
/// # Arguments /// # Arguments
/// ///
/// * `origin_server_ts` - optionally the `origin_server_ts` can be passed as a fallback in case /// * `origin_server_ts` - optionally the `origin_server_ts` can be passed as a fallback in the
/// the Membership does not contain `created_ts`. (`origin_server_ts` will be ignored if /// Membership does not contain [`LegacyMembershipData::created_ts`]. (`origin_server_ts` will
/// `created_ts` is `Some`) /// be ignored if [`LegacyMembershipData::created_ts`] is `Some`)
pub fn active_memberships( pub fn active_memberships(
&self, &self,
origin_server_ts: Option<MilliSecondsSinceUnixEpoch>, origin_server_ts: Option<MilliSecondsSinceUnixEpoch>,
) -> Vec<&Membership> { ) -> Vec<MembershipData<'_>> {
self.memberships.iter().filter(|m| !m.is_expired(origin_server_ts)).collect() 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`. /// 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 /// `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 /// (This allows to use `MinimalStateEvents` and still be able to determine if a membership is
/// expired) /// expired)
pub fn set_created_ts_if_none(&mut self, origin_server_ts: MilliSecondsSinceUnixEpoch) { pub fn set_created_ts_if_none(&mut self, origin_server_ts: MilliSecondsSinceUnixEpoch) {
self.memberships.iter_mut().for_each(|m| { match self {
CallMemberEventContent::LegacyContent(content) => {
content.memberships.iter_mut().for_each(|m: &mut LegacyMembershipData| {
m.created_ts.get_or_insert(origin_server_ts); m.created_ts.get_or_insert(origin_server_ts);
}); });
} }
CallMemberEventContent::SessionContent(m) => {
m.created_ts.get_or_insert(origin_server_ts);
}
_ => (),
}
}
} }
/// A membership describes one of the sessions this user currently partakes. /// This describes the CallMember event if the user is not part of the current session.
/// #[derive(Clone, PartialEq, Serialize, Deserialize, Debug)]
/// 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)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Membership { pub struct EmptyMembershipData {
/// The type of the matrixRTC session the membership belongs to. /// 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)
/// 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")] #[serde(skip_serializing_if = "Option::is_none")]
pub created_ts: Option<MilliSecondsSinceUnixEpoch>, pub leave_reason: Option<LeaveReason>,
/// 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 { /// This is the optional value for an empty membership event content:
/// The application of the membership is "m.call" and the scope is "m.room". /// [`CallMemberEventContent::Empty`]. It is used when the user disconnected and a Future ([MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140))
pub fn is_room_call(&self) -> bool { /// was used to update the membership after the client was not reachable anymore.
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
}
}
}
/// 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))]
#[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 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"))]
#[derive(Clone, PartialEq, StringEnum)] #[derive(Clone, PartialEq, StringEnum)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[ruma_enum(rename_all = "m.snake_case")] #[ruma_enum(rename_all = "m.snake_case")]
pub enum CallScope { pub enum LeaveReason {
/// A call which every user of a room can join and create. /// The user left the call by losing network connection or closing
/// /// the client before it was able to send the leave event.
/// There is no particular name associated with it. LostConnection,
///
/// 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)] #[doc(hidden)]
_Custom(PrivOwnedStr), _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)] #[cfg(test)]
mod tests { mod tests {
use std::time::Duration; use std::time::Duration;
use ruma_common::MilliSecondsSinceUnixEpoch as TS; use assert_matches2::assert_matches;
use serde_json::json; use ruma_common::{MilliSecondsSinceUnixEpoch as TS, OwnedEventId, OwnedRoomId, OwnedUserId};
use serde_json::{from_value as from_json_value, json};
use super::{ use super::{
Application, CallApplicationContent, CallMemberEventContent, CallScope, Focus, focus::{ActiveFocus, ActiveLivekitFocus, Focus, LivekitFocus},
LivekitFocus, Membership, member_data::{
Application, CallApplicationContent, CallScope, LegacyMembershipData, MembershipData,
},
CallMemberEventContent,
};
use crate::{
call::member::{EmptyMembershipData, FocusSelection, SessionMembershipData},
AnyStateEvent, StateEvent,
}; };
fn create_call_member_event_content() -> CallMemberEventContent { fn create_call_member_legacy_event_content() -> CallMemberEventContent {
CallMemberEventContent::new(vec![Membership { CallMemberEventContent::new_legacy(vec![LegacyMembershipData {
application: Application::Call(CallApplicationContent { application: Application::Call(CallApplicationContent {
call_id: "123456".to_owned(), call_id: "123456".to_owned(),
scope: CallScope::Room, 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] #[test]
fn serialize_call_member_event_content() { 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!({ let call_member_event = &json!({
"memberships": [ "memberships": [
{ {
@ -358,14 +336,62 @@ mod tests {
assert_eq!( assert_eq!(
call_member_event, 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] #[test]
fn deserialize_call_member_event_content() { fn deserialize_legacy_call_member_event_content() {
let call_member_ev: CallMemberEventContent = CallMemberEventContent::new(vec![ let call_member_ev = CallMemberEventContent::new_legacy(vec![
Membership { LegacyMembershipData {
application: Application::Call(CallApplicationContent { application: Application::Call(CallApplicationContent {
call_id: "123456".to_owned(), call_id: "123456".to_owned(),
scope: CallScope::Room, scope: CallScope::Room,
@ -379,7 +405,7 @@ mod tests {
membership_id: "0".to_owned(), membership_id: "0".to_owned(),
created_ts: None, created_ts: None,
}, },
Membership { LegacyMembershipData {
application: Application::Call(CallApplicationContent { application: Application::Call(CallApplicationContent {
call_id: "".to_owned(), call_id: "".to_owned(),
scope: CallScope::Room, scope: CallScope::Room,
@ -432,7 +458,85 @@ mod tests {
let ev_content: CallMemberEventContent = let ev_content: CallMemberEventContent =
serde_json::from_value(call_member_ev_json).unwrap(); 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) { fn timestamps() -> (TS, TS, TS) {
@ -449,44 +553,59 @@ mod tests {
} }
#[test] #[test]
fn membership_do_expire() { fn memberships_do_expire() {
let content = create_call_member_event_content(); let content_legacy = create_call_member_legacy_event_content();
let (now, one_second_ago, two_hours_ago) = timestamps(); let (now, one_second_ago, two_hours_ago) = timestamps();
assert_eq!( assert_eq!(
content.active_memberships(Some(one_second_ago)), content_legacy.active_memberships(Some(one_second_ago)),
content.memberships.iter().collect::<Vec<&Membership>>() content_legacy.memberships()
); );
assert_eq!(content_legacy.active_memberships(Some(now)), content_legacy.memberships());
assert_eq!( assert_eq!(
content.active_memberships(Some(now)), content_legacy.active_memberships(Some(two_hours_ago)),
content.memberships.iter().collect::<Vec<&Membership>>() (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] #[test]
fn set_created_ts() { fn set_created_ts() {
let mut content_now = create_call_member_event_content(); let mut content_now = create_call_member_legacy_event_content();
let mut content_two_hours_ago = create_call_member_event_content(); let mut content_two_hours_ago = create_call_member_legacy_event_content();
let mut content_one_second_ago = create_call_member_event_content(); let mut content_one_second_ago = create_call_member_legacy_event_content();
let (now, one_second_ago, two_hours_ago) = timestamps(); let (now, one_second_ago, two_hours_ago) = timestamps();
content_now.set_created_ts_if_none(now); content_now.set_created_ts_if_none(now);
content_one_second_ago.set_created_ts_if_none(one_second_ago); content_one_second_ago.set_created_ts_if_none(one_second_ago);
content_two_hours_ago.set_created_ts_if_none(two_hours_ago); content_two_hours_ago.set_created_ts_if_none(two_hours_ago);
assert_eq!( assert_eq!(content_now.active_memberships(None), content_now.memberships());
content_now.active_memberships(None),
content_now.memberships.iter().collect::<Vec<&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<'_>>
);
assert_eq!( assert_eq!(
content_one_second_ago.active_memberships(None), 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. // created_ts should not be overwritten.
content_two_hours_ago.set_created_ts_if_none(one_second_ago); content_two_hours_ago.set_created_ts_if_none(one_second_ago);
// There still should be no active membership. // 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 //! [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. //! 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; 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. /// This event is sent by any party when a stream metadata changes but no negotiation is required.
#[derive(Clone, Debug, Deserialize, Serialize, EventContent)] #[derive(Clone, Debug, Deserialize, Serialize, EventContent)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[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 { pub struct CallSdpStreamMetadataChangedEventContent {
/// A unique identifier for the call. /// A unique identifier for the call.
pub call_id: OwnedVoipId, 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. /// Event content that can be deserialized with its event type.
pub trait EventContentFromType: EventContent { pub trait EventContentFromType: EventContent {
/// Constructs this event content from the given event type and JSON. /// 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>; 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.candidates" => super::call::candidates,
"m.call.negotiate" => super::call::negotiate, "m.call.negotiate" => super::call::negotiate,
"m.call.reject" => super::call::reject, "m.call.reject" => super::call::reject,
#[cfg(feature = "unstable-msc3291")] #[ruma_enum(alias = "org.matrix.call.sdp_stream_metadata_changed")]
#[ruma_enum(alias = "m.call.sdp_stream_metadata_changed")] "m.call.sdp_stream_metadata_changed" => super::call::sdp_stream_metadata_changed,
"org.matrix.call.sdp_stream_metadata_changed" => super::call::sdp_stream_metadata_changed,
"m.call.select_answer" => super::call::select_answer, "m.call.select_answer" => super::call::select_answer,
#[cfg(feature = "unstable-msc3954")] #[cfg(feature = "unstable-msc3954")]
#[ruma_enum(alias = "m.emote")] #[ruma_enum(alias = "m.emote")]
@ -88,6 +87,9 @@ event_enum! {
#[cfg(feature = "unstable-msc3381")] #[cfg(feature = "unstable-msc3381")]
#[ruma_enum(ident = UnstablePollEnd)] #[ruma_enum(ident = UnstablePollEnd)]
"org.matrix.msc3381.poll.end" => super::poll::unstable_end, "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.reaction" => super::reaction,
"m.room.encrypted" => super::room::encrypted, "m.room.encrypted" => super::room::encrypted,
"m.room.message" => super::room::message, "m.room.message" => super::room::message,
@ -127,6 +129,9 @@ event_enum! {
"m.room.topic" => super::room::topic, "m.room.topic" => super::room::topic,
"m.space.child" => super::space::child, "m.space.child" => super::space::child,
"m.space.parent" => super::space::parent, "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")] #[cfg(feature = "unstable-msc3401")]
#[ruma_enum(alias = "m.call.member")] #[ruma_enum(alias = "m.call.member")]
"org.matrix.msc3401.call.member" => super::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 /// 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. /// `m.relates_to` without first destructuring an `AnyMessageLikeEventContent` otherwise.
pub fn relation(&self) -> Option<encrypted::Relation> { pub fn relation(&self) -> Option<encrypted::Relation> {
#[cfg(feature = "unstable-msc3489")]
use super::beacon::BeaconEventContent;
use super::key::verification::{ use super::key::verification::{
accept::KeyVerificationAcceptEventContent, cancel::KeyVerificationCancelEventContent, accept::KeyVerificationAcceptEventContent, cancel::KeyVerificationCancelEventContent,
done::KeyVerificationDoneEventContent, key::KeyVerificationKeyEventContent, done::KeyVerificationDoneEventContent, key::KeyVerificationKeyEventContent,
@ -361,13 +368,16 @@ impl AnyMessageLikeEventContent {
| Self::UnstablePollEnd(UnstablePollEndEventContent { relates_to, .. }) => { | Self::UnstablePollEnd(UnstablePollEndEventContent { relates_to, .. }) => {
Some(encrypted::Relation::Reference(relates_to.clone())) 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")] #[cfg(feature = "unstable-msc3381")]
Self::PollStart(_) | Self::UnstablePollStart(_) => None, Self::PollStart(_) | Self::UnstablePollStart(_) => None,
#[cfg(feature = "unstable-msc4075")] #[cfg(feature = "unstable-msc4075")]
Self::CallNotify(_) => None, Self::CallNotify(_) => None,
#[cfg(feature = "unstable-msc3291")] Self::CallSdpStreamMetadataChanged(_)
Self::CallSdpStreamMetadataChanged(_) => None, | Self::CallNegotiate(_)
Self::CallNegotiate(_)
| Self::CallReject(_) | Self::CallReject(_)
| Self::CallSelectAnswer(_) | Self::CallSelectAnswer(_)
| Self::CallAnswer(_) | Self::CallAnswer(_)

View File

@ -165,7 +165,10 @@ mod tests {
use std::collections::BTreeMap; use std::collections::BTreeMap;
use assert_matches2::assert_matches; use assert_matches2::assert_matches;
use ruma_common::{event_id, serde::Base64}; use ruma_common::{
event_id,
serde::{Base64, Raw},
};
use serde_json::{ use serde_json::{
from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, 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.message_authentication_code, MessageAuthenticationCode::HkdfHmacSha256V2);
assert_eq!(sas.short_authentication_string, vec![ShortAuthenticationString::Decimal]); 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")] #[cfg(feature = "unstable-msc3927")]
pub mod audio; pub mod audio;
#[cfg(feature = "unstable-msc3489")]
pub mod beacon;
#[cfg(feature = "unstable-msc3489")]
pub mod beacon_info;
pub mod call; pub mod call;
pub mod direct; pub mod direct;
pub mod dummy; pub mod dummy;

View File

@ -37,7 +37,7 @@ impl From<Annotation> for ReactionEventContent {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use assert_matches2::assert_matches; 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 serde_json::{from_value as from_json_value, json, to_value as to_json_value};
use super::ReactionEventContent; 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 /// [replaces another event]: https://spec.matrix.org/latest/client-server-api/#event-replacements
#[derive(Clone, Debug, Deserialize, Serialize)] #[derive(Clone, Debug, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(tag = "rel_type", rename = "m.replace")]
pub struct Replacement { pub struct Replacement {
/// The ID of the event being replaced. /// The ID of the event being replaced.
pub event_id: OwnedEventId, 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.serialize_field("m.in_reply_to", in_reply_to)?;
st.end() st.end()
} }
Relation::Replacement(data) => { Relation::Replacement(data) => data.serialize(serializer),
RelationSerHelper { rel_type: "m.replace", data }.serialize(serializer) Relation::Reference(data) => data.serialize(serializer),
} Relation::Annotation(data) => data.serialize(serializer),
Relation::Reference(data) => { Relation::Thread(data) => data.serialize(serializer),
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::_Custom(c) => c.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)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Restricted { pub struct Restricted {
/// Allow rules which describe conditions that allow joining a room. /// Allow rules which describe conditions that allow joining a room.
#[serde(default)]
pub allow: Vec<AllowRule>, pub allow: Vec<AllowRule>,
} }
@ -324,6 +325,16 @@ mod tests {
assert_eq!(serde_json::to_string(&allow_rule).unwrap(), json); 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] #[test]
fn join_rule_to_space_room_join_rule() { fn join_rule_to_space_room_join_rule() {
assert_eq!(SpaceRoomJoinRule::Invite, JoinRule::Invite.into()); assert_eq!(SpaceRoomJoinRule::Invite, JoinRule::Invite.into());

View File

@ -157,7 +157,7 @@ pub(super) enum RelationSerHelper {
Replacement(ReplacementJsonRepr), Replacement(ReplacementJsonRepr),
/// An event that belongs to a thread, with stable names. /// An event that belongs to a thread, with stable names.
#[serde(rename = "m.thread")] #[serde(untagged)]
Thread(Thread), Thread(Thread),
/// An unknown relation type. /// 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 /// The only algorithm currently specified is `m.secret_storage.v1.aes-hmac-sha2`, so this
/// essentially represents `AesHmacSha2KeyDescription` in the /// 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)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[derive(Clone, Debug, Serialize, EventContent)] #[derive(Clone, Debug, Serialize, EventContent)]
#[ruma_event(type = "m.secret_storage.key.*", kind = GlobalAccountData)] #[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. /// The key properties for the `m.secret_storage.v1.aes-hmac-sha2` algorithm.
/// ///
/// Corresponds to the AES-specific properties of `AesHmacSha2KeyDescription` in the /// 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)] #[derive(Debug, Clone, Deserialize, Serialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct SecretStorageV1AesHmacSha2Properties { pub struct SecretStorageV1AesHmacSha2Properties {
@ -182,7 +182,7 @@ mod tests {
PassPhrase, SecretStorageEncryptionAlgorithm, SecretStorageKeyEventContent, PassPhrase, SecretStorageEncryptionAlgorithm, SecretStorageKeyEventContent,
SecretStorageV1AesHmacSha2Properties, SecretStorageV1AesHmacSha2Properties,
}; };
use crate::{EventContentFromType, GlobalAccountDataEvent}; use crate::{AnyGlobalAccountDataEvent, EventContentFromType, GlobalAccountDataEvent};
#[test] #[test]
fn key_description_serialization() { fn key_description_serialization() {
@ -326,7 +326,7 @@ mod tests {
} }
#[test] #[test]
fn event_serialization() { fn event_content_serialization() {
let mut content = SecretStorageKeyEventContent::new( let mut content = SecretStorageKeyEventContent::new(
"my_key_id".into(), "my_key_id".into(),
SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties { SecretStorageEncryptionAlgorithm::V1AesHmacSha2(SecretStorageV1AesHmacSha2Properties {
@ -346,6 +346,31 @@ mod tests {
assert_eq!(to_json_value(&content).unwrap(), json); 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] #[test]
fn event_deserialization() { fn event_deserialization() {
let json = json!({ let json = json!({
@ -358,8 +383,8 @@ mod tests {
} }
}); });
let ev = let any_ev = from_json_value::<AnyGlobalAccountDataEvent>(json).unwrap();
from_json_value::<GlobalAccountDataEvent<SecretStorageKeyEventContent>>(json).unwrap(); assert_matches!(any_ev, AnyGlobalAccountDataEvent::SecretStorageKey(ev));
assert_eq!(ev.content.key_id, "my_key_id"); assert_eq!(ev.content.key_id, "my_key_id");
assert_eq!(ev.content.name.unwrap(), "my_key"); assert_eq!(ev.content.name.unwrap(), "my_key");
assert_matches!(ev.content.passphrase, None); 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; use assert_matches2::assert_matches;
#[cfg(feature = "unstable-msc2747")] #[cfg(feature = "unstable-msc2747")]
use assign::assign; use assign::assign;
@ -8,11 +5,6 @@ use js_int::uint;
use ruma_common::{room_id, serde::CanBeEmpty, MilliSecondsSinceUnixEpoch, VoipVersionId}; use ruma_common::{room_id, serde::CanBeEmpty, MilliSecondsSinceUnixEpoch, VoipVersionId};
#[cfg(feature = "unstable-msc2747")] #[cfg(feature = "unstable-msc2747")]
use ruma_events::call::CallCapabilities; use ruma_events::call::CallCapabilities;
#[cfg(feature = "unstable-msc4075")]
use ruma_events::{
call::notify::{ApplicationType, CallNotifyEventContent, NotifyType},
Mentions,
};
use ruma_events::{ use ruma_events::{
call::{ call::{
answer::CallAnswerEventContent, answer::CallAnswerEventContent,
@ -616,83 +608,3 @@ fn select_v1_answer_event_deserialization() {
assert_eq!(content.selected_party_id, "6336"); assert_eq!(content.selected_party_id, "6336");
assert_eq!(content.version, VoipVersionId::V1); 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 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::{ use ruma_events::{
relation::{CustomRelation, InReplyTo, Reference, Thread}, relation::{Annotation, CustomRelation, InReplyTo, Reference, Thread},
room::encrypted::{ room::encrypted::{
EncryptedEventScheme, MegolmV1AesSha2ContentInit, Relation, Replacement, EncryptedEventScheme, MegolmV1AesSha2ContentInit, Relation, Replacement,
RoomEncryptedEventContent, RoomEncryptedEventContent,
@ -82,6 +82,17 @@ fn content_no_relation_deserialization() {
assert_matches!(content.relates_to, None); 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] #[test]
fn content_reply_serialization() { fn content_reply_serialization() {
let content = RoomEncryptedEventContent::new( let content = RoomEncryptedEventContent::new(
@ -149,6 +160,22 @@ fn content_reply_deserialization() {
assert_eq!(in_reply_to.event_id, "$replied_to_event"); 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] #[test]
fn content_replacement_serialization() { fn content_replacement_serialization() {
let content = RoomEncryptedEventContent::new( let content = RoomEncryptedEventContent::new(
@ -214,6 +241,22 @@ fn content_replacement_deserialization() {
assert_eq!(replacement.event_id, "$replaced_event"); 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] #[test]
fn content_reference_serialization() { fn content_reference_serialization() {
let content = RoomEncryptedEventContent::new( let content = RoomEncryptedEventContent::new(
@ -279,6 +322,22 @@ fn content_reference_deserialization() {
assert_eq!(reference.event_id, "$referenced_event"); 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] #[test]
fn content_thread_serialization() { fn content_thread_serialization() {
let content = RoomEncryptedEventContent::new( let content = RoomEncryptedEventContent::new(
@ -357,9 +416,23 @@ fn content_thread_deserialization() {
} }
#[test] #[test]
fn content_annotation_serialization() { fn content_thread_serialization_roundtrip() {
use ruma_events::relation::Annotation; 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( let content = RoomEncryptedEventContent::new(
encrypted_scheme(), encrypted_scheme(),
Some(Relation::Annotation(Annotation::new( Some(Relation::Annotation(Annotation::new(
@ -429,6 +502,23 @@ fn content_annotation_deserialization() {
assert_eq!(annotation.key, "some_key"); 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] #[test]
fn custom_relation_deserialization() { fn custom_relation_deserialization() {
let relation_json = json!({ 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 assert_matches2::assert_matches;
use js_int::uint; use js_int::uint;
use ruma_common::{serde::CanBeEmpty, MilliSecondsSinceUnixEpoch, VoipVersionId}; use ruma_common::{
use ruma_events::{AnyMessageLikeEvent, MessageLikeEvent}; serde::{CanBeEmpty, Raw},
use serde_json::{from_value as from_json_value, json}; 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] #[test]
fn ui() { fn ui() {
@ -46,3 +53,53 @@ fn deserialize_message_event() {
assert_eq!(content.call_id, "foofoo"); assert_eq!(content.call_id, "foofoo");
assert_eq!(content.version, VoipVersionId::V0); 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 audio;
mod beacon;
mod beacon_info;
mod call; mod call;
mod encrypted; mod encrypted;
mod enums; mod enums;

View File

@ -1,6 +1,6 @@
use assert_matches2::assert_matches; use assert_matches2::assert_matches;
use assign::assign; use assign::assign;
use ruma_common::owned_event_id; use ruma_common::{owned_event_id, serde::Raw};
use ruma_events::{ use ruma_events::{
relation::{CustomRelation, InReplyTo, Replacement, Thread}, relation::{CustomRelation, InReplyTo, Replacement, Thread},
room::message::{MessageType, Relation, RoomMessageEventContent}, 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] #[test]
fn replacement_serialize() { fn replacement_serialize() {
let content = assign!( let content = assign!(
@ -111,6 +127,28 @@ fn replacement_deserialize() {
assert_eq!(text.body, "Hello! My name is bar"); 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] #[test]
fn thread_plain_serialize() { fn thread_plain_serialize() {
let content = assign!( let content = assign!(
@ -250,6 +288,25 @@ fn thread_unstable_deserialize() {
assert!(!thread.is_falling_back); 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] #[test]
fn custom_deserialize() { fn custom_deserialize() {
let relation_json = json!({ 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, EncryptedFileInit, JsonWebKeyInit, MediaSource,
}, },
AnySyncTimelineEvent, Mentions, MessageLikeUnsigned, AnySyncTimelineEvent, EventContent, Mentions, MessageLikeUnsigned, RawExt,
}; };
use serde_json::{ use serde_json::{
from_value as from_json_value, json, to_value as to_json_value, Value as JsonValue, 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); 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] #[test]
fn reply_add_mentions() { fn reply_add_mentions() {
let user = owned_user_id!("@user:example.org"); let user = owned_user_id!("@user:example.org");

View File

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

View File

@ -1,5 +1,13 @@
# [unreleased] # [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 # 0.2.0
Breaking Changes: Breaking Changes:

View File

@ -152,7 +152,7 @@ pub enum MatrixElement {
/// [`<div>`], a content division element. /// [`<div>`], a content division element.
/// ///
/// [`<div>`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div /// [`<div>`]: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/div
Div, Div(DivData),
/// [`<table>`], a table element. /// [`<table>`], a table element.
/// ///
@ -268,7 +268,10 @@ impl MatrixElement {
} }
b"hr" => (Self::Hr, attrs.clone()), b"hr" => (Self::Hr, attrs.clone()),
b"br" => (Self::Br, 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"table" => (Self::Table, attrs.clone()),
b"thead" => (Self::Thead, attrs.clone()), b"thead" => (Self::Thead, attrs.clone()),
b"tbody" => (Self::Tbody, 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 /// [spoiler message]: https://spec.matrix.org/latest/client-server-api/#spoiler-messages
pub spoiler: Option<StrTendril>, 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 { impl SpanData {
/// Construct an empty `SpanData`. /// Construct an empty `SpanData`.
fn new() -> Self { 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`. /// Parse the given attributes to construct a new `SpanData`.
@ -629,6 +643,9 @@ impl SpanData {
b"data-mx-spoiler" => { b"data-mx-spoiler" => {
data.spoiler = Some(attr.value.clone()); data.spoiler = Some(attr.value.clone());
} }
b"data-mx-maths" => {
data.maths = Some(attr.value.clone());
}
_ => { _ => {
remaining_attrs.insert(attr.clone()); remaining_attrs.insert(attr.clone());
} }
@ -722,3 +739,53 @@ impl ImageData {
(data, remaining_attrs) (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, "img" => &ALLOWED_ATTRIBUTES_IMG_STRICT,
"ol" => &ALLOWED_ATTRIBUTES_OL_STRICT, "ol" => &ALLOWED_ATTRIBUTES_OL_STRICT,
"code" => &ALLOWED_ATTRIBUTES_CODE_STRICT, "code" => &ALLOWED_ATTRIBUTES_CODE_STRICT,
"div" => &ALLOWED_ATTRIBUTES_DIV_STRICT,
}; };
static ALLOWED_ATTRIBUTES_SPAN_STRICT: Set<&str> = 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_A_STRICT: Set<&str> = phf_set! { "name", "target", "href" };
static ALLOWED_ATTRIBUTES_IMG_STRICT: Set<&str> = static ALLOWED_ATTRIBUTES_IMG_STRICT: Set<&str> =
phf_set! { "width", "height", "alt", "title", "src" }; phf_set! { "width", "height", "alt", "title", "src" };
static ALLOWED_ATTRIBUTES_OL_STRICT: Set<&str> = phf_set! { "start" }; static ALLOWED_ATTRIBUTES_OL_STRICT: Set<&str> = phf_set! { "start" };
static ALLOWED_ATTRIBUTES_CODE_STRICT: Set<&str> = phf_set! { "class" }; 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, /// Attributes that were previously allowed on HTML elements according to the Matrix specification,
/// with their replacement. /// with their replacement.

View File

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

View File

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

View File

@ -77,7 +77,7 @@ pub enum MxcUriError {
/// Media identifier malformed due to invalid characters detected. /// Media identifier malformed due to invalid characters detected.
/// ///
/// Valid characters are (in regex notation) `[A-Za-z0-9_-]+`. /// 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")] #[error("Media Identifier malformed, invalid characters")]
MediaIdMalformed, MediaIdMalformed,

View File

@ -17,7 +17,7 @@ pub fn validate(uri: &str) -> Result<NonZeroU8, MxcUriError> {
let server_name = &uri[..index]; let server_name = &uri[..index];
let media_id = &uri[index + 1..]; 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 let media_id_is_valid = media_id
.bytes() .bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'-' | b'_' )); .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!(raw_body);
syn::custom_keyword!(path); syn::custom_keyword!(path);
syn::custom_keyword!(query); syn::custom_keyword!(query);
syn::custom_keyword!(query_map); syn::custom_keyword!(query_all);
syn::custom_keyword!(header); syn::custom_keyword!(header);
syn::custom_keyword!(error); syn::custom_keyword!(error);
syn::custom_keyword!(manual_body_serde); syn::custom_keyword!(manual_body_serde);
@ -22,7 +22,7 @@ pub enum RequestMeta {
RawBody, RawBody,
Path, Path,
Query, Query,
QueryMap, QueryAll,
Header(Ident), Header(Ident),
} }
@ -41,9 +41,9 @@ impl Parse for RequestMeta {
} else if lookahead.peek(kw::query) { } else if lookahead.peek(kw::query) {
let _: kw::query = input.parse()?; let _: kw::query = input.parse()?;
Ok(Self::Query) Ok(Self::Query)
} else if lookahead.peek(kw::query_map) { } else if lookahead.peek(kw::query_all) {
let _: kw::query_map = input.parse()?; let _: kw::query_all = input.parse()?;
Ok(Self::QueryMap) Ok(Self::QueryAll)
} else if lookahead.peek(kw::header) { } else if lookahead.peek(kw::header) {
let _: kw::header = input.parse()?; let _: kw::header = input.parse()?;
let _: Token![=] = input.parse()?; let _: Token![=] = input.parse()?;

View File

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

View File

@ -31,7 +31,7 @@ impl Request {
(TokenStream::new(), TokenStream::new()) (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 = let cfg_attrs =
field.attrs.iter().filter(|a| a.path().is_ident("cfg")).collect::<Vec<_>>(); 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"); 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 = let path_fields =
self.path_fields().map(|f| f.ident.as_ref().expect("path fields have a name")); 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"); let field_name = field.ident.as_ref().expect("expected field to have identifier");
quote! {{ 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); let request_query = RequestQuery(self.#field_name);
assert_trait_impl(&request_query.0);
&#serde_html_form::to_string(request_query)? &#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 self_variant = variant.ctor(quote! { Self });
let content = event.to_event_path(kind, var); 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! { Ok(quote! {
#variant_attrs #(#ev_types)|* => { #variant_attrs #(#ev_types)|* => {
@ -328,6 +338,55 @@ fn expand_content_enum(
let serialize_custom_event_error_path = let serialize_custom_event_error_path =
quote! { #ruma_events::serialize_custom_event_error }.to_string(); 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! { Ok(quote! {
#( #attrs )* #( #attrs )*
#[derive(Clone, Debug, #serde::Serialize)] #[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] #[automatically_derived]
impl #ruma_events::#sub_trait_name for #ident { impl #ruma_events::#sub_trait_name for #ident {
#state_event_content_impl #state_event_content_impl

View File

@ -4,6 +4,19 @@ Breaking changes:
- The `XMatrix::new` method now takes `OwnedServerName` instead of `Option<OwnedServerName>` - The `XMatrix::new` method now takes `OwnedServerName` instead of `Option<OwnedServerName>`
for the destination, since servers must always set the destination. 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 # 0.3.0

View File

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

View File

@ -1,30 +1,38 @@
//! Common types for implementing federation authorization. //! Common types for implementing federation authorization.
use headers::{authorization::Credentials, HeaderValue}; use std::{borrow::Cow, fmt, str::FromStr};
use ruma_common::{OwnedServerName, OwnedServerSigningKeyId};
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 tracing::debug;
use yap::{IntoTokens, TokenLocation, Tokens};
/// Typed representation of an `Authorization` header of scheme `X-Matrix`, as defined in the /// Typed representation of an `Authorization` header of scheme `X-Matrix`, as defined in the
/// [Matrix Server-Server API][spec]. Includes an implementation of /// [Matrix Server-Server API][spec].
/// [`headers::authorization::Credentials`] for automatically handling the encoding and decoding
/// when using a web framework that supports typed headers.
/// ///
/// [spec]: https://spec.matrix.org/latest/server-server-api/#request-authentication /// [spec]: https://spec.matrix.org/latest/server-server-api/#request-authentication
#[derive(Clone)]
#[non_exhaustive] #[non_exhaustive]
pub struct XMatrix { pub struct XMatrix {
/// The server name of the sending server. /// The server name of the sending server.
pub origin: OwnedServerName, pub origin: OwnedServerName,
/// The server name of the receiving sender. For compatibility with older servers, recipients /// The server name of the receiving sender.
/// 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 /// For compatibility with older servers, recipients should accept requests without this
/// must deny the request with an HTTP status code 401 Unauthorized. /// 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>, pub destination: Option<OwnedServerName>,
/// The ID - including the algorithm name - of the sending server's key that was used to sign /// The ID - including the algorithm name - of the sending server's key that was used to sign
/// the request. /// the request.
pub key: OwnedServerSigningKeyId, pub key: OwnedServerSigningKeyId,
/// The signature of the JSON. /// The signature of the JSON.
pub sig: String, pub sig: Base64,
} }
impl XMatrix { impl XMatrix {
@ -33,227 +41,214 @@ impl XMatrix {
origin: OwnedServerName, origin: OwnedServerName,
destination: OwnedServerName, destination: OwnedServerName,
key: OwnedServerSigningKeyId, key: OwnedServerSigningKeyId,
sig: String, sig: Base64,
) -> Self { ) -> Self {
Self { origin, destination: Some(destination), key, sig } Self { origin, destination: Some(destination), key, sig }
} }
}
fn parse_token<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> { /// Parse an X-Matrix Authorization header from the given string.
tokens.optional(|t| { pub fn parse(s: impl AsRef<str>) -> Result<Self, XMatrixParseError> {
let token: Vec<u8> = t.take_while(|c| is_tchar(**c)).as_iter().copied().collect(); let parser = ChallengeParser::new(s.as_ref());
if !token.is_empty() { let mut xmatrix = None;
Some(token)
} else {
debug!("Returning early because of empty token at {}", t.location().offset());
None
}
})
}
fn parse_token_with_colons<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> { for challenge in parser {
tokens.optional(|t| { let challenge = challenge?;
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
}
})
}
fn parse_quoted<'a>(tokens: &mut impl Tokens<Item = &'a u8>) -> Option<Vec<u8>> { if challenge.scheme.eq_ignore_ascii_case(XMatrix::SCHEME) {
tokens.optional(|t| { xmatrix = Some(challenge);
if !(t.token(&b'"')) { break;
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;
}
}
}
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
}
}
})?;
if !t.token(&b'=') {
return None;
} }
let value = parse_quoted(t).or_else(|| parse_token_with_colons(t))?; let Some(xmatrix) = xmatrix else {
return Err(XMatrixParseError::NotFound);
};
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 origin = None;
let mut destination = None; let mut destination = None;
let mut key = None; let mut key = None;
let mut sig = None; let mut sig = None;
for (name, value) in t.sep_by(|t| parse_xmatrix_field(t), |t| t.token(&b',')).as_iter() { for (name, value) in xmatrix.params {
match name.as_str() { if name.eq_ignore_ascii_case("origin") {
"origin" => {
if origin.is_some() { if origin.is_some() {
debug!("Field origin duplicated in X-Matrix Authorization header"); return Err(XMatrixParseError::DuplicateParameter("origin".to_owned()));
} else {
origin = Some(OwnedServerName::try_from(value.to_unescaped())?);
} }
origin = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); } else if name.eq_ignore_ascii_case("destination") {
}
"destination" => {
if destination.is_some() { if destination.is_some() {
debug!("Field destination duplicated in X-Matrix Authorization header"); return Err(XMatrixParseError::DuplicateParameter("destination".to_owned()));
} else {
destination = Some(OwnedServerName::try_from(value.to_unescaped())?);
} }
destination = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); } else if name.eq_ignore_ascii_case("key") {
}
"key" => {
if key.is_some() { if key.is_some() {
debug!("Field key duplicated in X-Matrix Authorization header"); return Err(XMatrixParseError::DuplicateParameter("key".to_owned()));
} else {
key = Some(OwnedServerSigningKeyId::try_from(value.to_unescaped())?);
} }
key = Some(std::str::from_utf8(&value).ok()?.try_into().ok()?); } else if name.eq_ignore_ascii_case("sig") {
}
"sig" => {
if sig.is_some() { if sig.is_some() {
debug!("Field sig duplicated in X-Matrix Authorization header"); return Err(XMatrixParseError::DuplicateParameter("sig".to_owned()));
} } else {
sig = Some(std::str::from_utf8(&value).ok()?.to_owned()); sig = Some(Base64::parse(value.to_unescaped())?);
}
name => {
debug!("Unknown field {} found in X-Matrix Authorization header", name);
} }
} 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 { impl fmt::Debug for XMatrix {
(0x41..=0x5A).contains(&c) || (0x61..=0x7A).contains(&c) 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 { /// Whether the given char is a [token char].
(0x30..=0x39).contains(&c) ///
/// [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 { /// If the field value does not contain only token chars, convert it to a [quoted string].
const TOKEN_CHARS: [u8; 15] = ///
[b'!', b'#', b'$', b'%', b'&', b'\'', b'*', b'+', b'-', b'.', b'^', b'_', b'`', b'|', b'~']; /// [quoted string]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4
is_alpha(c) || is_digit(c) || TOKEN_CHARS.contains(&c) 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 { impl fmt::Display for XMatrix {
c == b'\t' fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|| c == b' ' let Self { origin, destination, key, sig } = self;
|| c == 0x21
|| (0x23..=0x5B).contains(&c) let origin = escape_field_value(origin.as_str());
|| (0x5D..=0x7E).contains(&c) let key = escape_field_value(key.as_str());
|| is_obs_text(c) 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 { impl FromStr for XMatrix {
c >= 0x80 // The spec does contain an upper limit of 0xFF here, but that's enforced by the type type Err = XMatrixParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
} }
fn is_vchar(c: u8) -> bool { impl TryFrom<&HeaderValue> for XMatrix {
(0x21..=0x7E).contains(&c) type Error = XMatrixParseError;
fn try_from(value: &HeaderValue) -> Result<Self, Self::Error> {
Self::parse(value.to_str()?)
}
} }
fn is_quoted_pair(c: u8) -> bool { impl From<&XMatrix> for HeaderValue {
c == b'\t' || c == b' ' || is_vchar(c) || is_obs_text(c) fn from(value: &XMatrix) -> Self {
value.to_string().try_into().expect("header format is static")
}
} }
impl Credentials for XMatrix { impl Credentials for XMatrix {
const SCHEME: &'static str = "X-Matrix"; const SCHEME: &'static str = "X-Matrix";
fn decode(value: &HeaderValue) -> Option<Self> { fn decode(value: &HeaderValue) -> Option<Self> {
let value: Vec<u8> = value.as_bytes().to_vec(); value.try_into().ok()
parse_xmatrix(&mut value.into_tokens())
} }
fn encode(&self) -> HeaderValue { fn encode(&self) -> HeaderValue {
if let Some(destination) = &self.destination { self.into()
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")
/// 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)] #[cfg(test)]
mod tests { mod tests {
use headers::{authorization::Credentials, HeaderValue}; use headers::{authorization::Credentials, HeaderValue};
use ruma_common::OwnedServerName; use ruma_common::{serde::Base64, OwnedServerName};
use super::XMatrix; use super::XMatrix;
#[test] #[test]
fn xmatrix_auth_pre_1_3() { fn xmatrix_auth_pre_1_3() {
let header = HeaderValue::from_static( 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 origin = "origin.hs.example.com".try_into().unwrap();
let key = "ed25519:key1".try_into().unwrap(); let key = "ed25519:key1".try_into().unwrap();
let sig = "ABCDEF...".to_owned(); let sig = Base64::new(b"test".to_vec());
let credentials: XMatrix = Credentials::decode(&header).unwrap(); let credentials = XMatrix::try_from(&header).unwrap();
assert_eq!(credentials.origin, origin); assert_eq!(credentials.origin, origin);
assert_eq!(credentials.destination, None); assert_eq!(credentials.destination, None);
assert_eq!(credentials.key, key); assert_eq!(credentials.key, key);
@ -261,17 +256,20 @@ mod tests {
let credentials = XMatrix { origin, destination: None, key, sig }; 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] #[test]
fn xmatrix_auth_1_3() { 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 origin: OwnedServerName = "origin.hs.example.com".try_into().unwrap();
let destination: OwnedServerName = "destination.hs.example.com".try_into().unwrap(); let destination: OwnedServerName = "destination.hs.example.com".try_into().unwrap();
let key = "ed25519:key1".try_into().unwrap(); let key = "ed25519:key1".try_into().unwrap();
let sig = "ABCDEF...".to_owned(); let sig = Base64::new(b"test".to_vec());
let credentials: XMatrix = Credentials::decode(&header).unwrap(); let credentials = XMatrix::try_from(&header).unwrap();
assert_eq!(credentials.origin, origin); assert_eq!(credentials.origin, origin);
assert_eq!(credentials.destination, Some(destination.clone())); assert_eq!(credentials.destination, Some(destination.clone()));
assert_eq!(credentials.key, key); assert_eq!(credentials.key, key);
@ -279,6 +277,41 @@ mod tests {
let credentials = XMatrix::new(origin, destination, key, sig); 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-msc2654 = ["ruma-client-api?/unstable-msc2654"]
unstable-msc2666 = ["ruma-client-api?/unstable-msc2666"] unstable-msc2666 = ["ruma-client-api?/unstable-msc2666"]
unstable-msc2705 = ["ruma-client-api?/unstable-msc2705"]
unstable-msc2747 = ["ruma-events?/unstable-msc2747"] unstable-msc2747 = ["ruma-events?/unstable-msc2747"]
unstable-msc2867 = ["ruma-events?/unstable-msc2867"] unstable-msc2867 = ["ruma-events?/unstable-msc2867"]
unstable-msc2870 = ["ruma-common/unstable-msc2870"] 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-msc3245-v1-compat = ["ruma-events?/unstable-msc3245-v1-compat"]
unstable-msc3246 = ["ruma-events?/unstable-msc3246"] unstable-msc3246 = ["ruma-events?/unstable-msc3246"]
unstable-msc3266 = ["ruma-client-api?/unstable-msc3266"] unstable-msc3266 = ["ruma-client-api?/unstable-msc3266"]
unstable-msc3291 = ["ruma-events?/unstable-msc3291"]
unstable-msc3381 = ["ruma-events?/unstable-msc3381"] unstable-msc3381 = ["ruma-events?/unstable-msc3381"]
unstable-msc3401 = ["ruma-events?/unstable-msc3401"] unstable-msc3401 = ["ruma-events?/unstable-msc3401"]
unstable-msc3488 = ["ruma-client-api?/unstable-msc3488", "ruma-events?/unstable-msc3488"] unstable-msc3488 = ["ruma-client-api?/unstable-msc3488", "ruma-events?/unstable-msc3488"]
unstable-msc3489 = ["ruma-events?/unstable-msc3489"]
unstable-msc3551 = ["ruma-events?/unstable-msc3551"] unstable-msc3551 = ["ruma-events?/unstable-msc3551"]
unstable-msc3552 = ["ruma-events?/unstable-msc3552"] unstable-msc3552 = ["ruma-events?/unstable-msc3552"]
unstable-msc3553 = ["ruma-events?/unstable-msc3553"] 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-msc3723 = ["ruma-federation-api?/unstable-msc3723"]
unstable-msc3814 = ["ruma-client-api?/unstable-msc3814"] unstable-msc3814 = ["ruma-client-api?/unstable-msc3814"]
unstable-msc3843 = ["ruma-client-api?/unstable-msc3843", "ruma-federation-api?/unstable-msc3843"] 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-msc3927 = ["ruma-events?/unstable-msc3927"]
unstable-msc3930 = ["ruma-common/unstable-msc3930"] unstable-msc3930 = ["ruma-common/unstable-msc3930"]
unstable-msc3931 = ["ruma-common/unstable-msc3931"] unstable-msc3931 = ["ruma-common/unstable-msc3931"]
@ -268,6 +266,7 @@ unstable-msc4075 = ["ruma-events?/unstable-msc4075"]
unstable-msc4108 = ["ruma-client-api?/unstable-msc4108"] unstable-msc4108 = ["ruma-client-api?/unstable-msc4108"]
unstable-msc4121 = ["ruma-client-api?/unstable-msc4121"] unstable-msc4121 = ["ruma-client-api?/unstable-msc4121"]
unstable-msc4125 = ["ruma-federation-api?/unstable-msc4125"] unstable-msc4125 = ["ruma-federation-api?/unstable-msc4125"]
unstable-msc4140 = ["ruma-client-api?/unstable-msc4140"]
unstable-pdu = ["ruma-events?/unstable-pdu"] unstable-pdu = ["ruma-events?/unstable-pdu"]
unstable-unspecified = [ unstable-unspecified = [
"ruma-common/unstable-unspecified", "ruma-common/unstable-unspecified",
@ -285,7 +284,6 @@ __ci = [
"unstable-msc2448", "unstable-msc2448",
"unstable-msc2654", "unstable-msc2654",
"unstable-msc2666", "unstable-msc2666",
"unstable-msc2705",
"unstable-msc2747", "unstable-msc2747",
"unstable-msc2867", "unstable-msc2867",
"unstable-msc2870", "unstable-msc2870",
@ -297,10 +295,10 @@ __ci = [
"unstable-msc3245-v1-compat", "unstable-msc3245-v1-compat",
"unstable-msc3246", "unstable-msc3246",
"unstable-msc3266", "unstable-msc3266",
"unstable-msc3291",
"unstable-msc3381", "unstable-msc3381",
"unstable-msc3401", "unstable-msc3401",
"unstable-msc3488", "unstable-msc3488",
"unstable-msc3489",
"unstable-msc3551", "unstable-msc3551",
"unstable-msc3552", "unstable-msc3552",
"unstable-msc3553", "unstable-msc3553",
@ -310,7 +308,6 @@ __ci = [
"unstable-msc3723", "unstable-msc3723",
"unstable-msc3814", "unstable-msc3814",
"unstable-msc3843", "unstable-msc3843",
"unstable-msc3916",
"unstable-msc3927", "unstable-msc3927",
"unstable-msc3930", "unstable-msc3930",
"unstable-msc3931", "unstable-msc3931",
@ -323,6 +320,7 @@ __ci = [
"unstable-msc4108", "unstable-msc4108",
"unstable-msc4121", "unstable-msc4121",
"unstable-msc4125", "unstable-msc4125",
"unstable-msc4140"
] ]
[dependencies] [dependencies]

View File

@ -17,7 +17,7 @@ const OLD_URL_WHITELIST: &[&str] =
/// Authorized versions in URLs pointing to the new specs. /// Authorized versions in URLs pointing to the new specs.
const NEW_VERSION_WHITELIST: &[&str] = &[ 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", "latest",
// This should only be enabled if a legitimate use case is found. // This should only be enabled if a legitimate use case is found.
// "unstable", // "unstable",
@ -220,48 +220,72 @@ fn get_page_ids(url: &str) -> Result<HashMap<String, HasDuplicates>> {
continue; 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) = let Some(id) =
tag.attributes.get(b"id".as_slice()).and_then(|s| String::from_utf8(s.0.clone()).ok()) tag.attributes.get(b"id".as_slice()).and_then(|s| String::from_utf8(s.0.clone()).ok())
else { else {
continue; 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); ids.insert(id, has_duplicates);
} }
Ok(ids) 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 /// This check is necessary because duplicates IDs have a number depending on their occurrence in a
/// are not unique in the source. /// HTML page. If a duplicate ID is added, moved or removed from the spec, its number might change
/// /// from one version to the next.
/// This is a reimplementation of the algorithm used for the spec. fn heading_id_has_duplicates(
/// id: &str,
/// See <https://github.com/matrix-org/matrix-spec/blob/6b02e393082570db2d0a651ddb79a365bc4a0f8d/static/js/toc.js#L25-L37>.
fn uniquify_heading_id(
mut id: String,
unique_ids: &mut HashMap<String, HasDuplicates>, unique_ids: &mut HashMap<String, HasDuplicates>,
) -> (String, HasDuplicates) { ) -> HasDuplicates {
let base_id = id.clone(); // IDs that should be duplicates end with `-{number}`.
let mut counter: u16 = 0; let Some((start, _end)) =
let mut has_duplicates = HasDuplicates::No; 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) { // Update the first duplicate ID, because it doesn't end with a number.
has_duplicates = HasDuplicates::Yes; if let Some(other_id_has_dup) = unique_ids.get_mut(start) {
*other_id_has_dup = HasDuplicates::Yes; *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) { fn print_link_err(error: &str, link: &SpecLink) {
println!( println!(
"\n{error}\nfile: {}:{}\nlink: {}", "\n{error}\n file: {}:{}\n link: {}",
link.path.display(), link.path.display(),
link.line, link.line,
link.url.get(..80).unwrap_or(&link.url), link.url.get(..80).unwrap_or(&link.url),