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

This commit is contained in:
strawberry 2024-08-14 01:47:43 -04:00
commit 69b2bc4b8c
63 changed files with 2708 additions and 523 deletions

View File

@ -4,7 +4,7 @@ env:
CARGO_TERM_COLOR: always CARGO_TERM_COLOR: always
CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse CARGO_REGISTRIES_CRATES_IO_PROTOCOL: sparse
# Keep in sync with version in `rust-toolchain.toml` and `xtask/src/ci.rs` # Keep in sync with version in `rust-toolchain.toml` and `xtask/src/ci.rs`
NIGHTLY: nightly-2024-05-09 NIGHTLY: nightly-2024-07-29
on: on:
push: push:
@ -181,6 +181,11 @@ jobs:
cmd: clippy-all cmd: clippy-all
components: clippy components: clippy
- name: Clippy WASM
cmd: clippy-wasm
targets: wasm32-unknown-unknown
components: clippy
steps: steps:
- name: Checkout repo - name: Checkout repo
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -190,6 +195,7 @@ jobs:
with: with:
toolchain: ${{ env.NIGHTLY }} toolchain: ${{ env.NIGHTLY }}
components: ${{ matrix.components }} components: ${{ matrix.components }}
targets: ${{ matrix.targets }}
- uses: Swatinem/rust-cache@v2 - uses: Swatinem/rust-cache@v2

25
.wasm/.clippy.toml Normal file
View File

@ -0,0 +1,25 @@
avoid-breaking-exported-api = false
disallowed-methods = [
# https://github.com/serde-rs/json/issues/160
"serde_json::from_reader",
]
disallowed-types = [
"std::collections::HashMap",
"std::collections::HashSet",
{ path = "std::time::UNIX_EPOCH", reason = "Use web-time to return a UNIX_EPOCH that works under WASM" },
{ path = "std::time::SystemTime", reason = "Use web-time to return a SystemTime that works under WASM" },
{ path = "std::time::Instant", reason = "Use web-time to return an Instant that works under WASM" },
]
enforced-import-renames = [
{ path = "serde_json::from_slice", rename = "from_json_slice" },
{ path = "serde_json::from_str", rename = "from_json_str" },
{ path = "serde_json::from_value", rename = "from_json_value" },
{ path = "serde_json::to_value", rename = "to_json_value" },
{ path = "serde_json::value::to_raw_value", rename = "to_raw_json_value" },
{ path = "serde_json::value::RawValue", rename = "RawJsonValue" },
{ path = "serde_json::Value", rename = "JsonValue" },
]
standard-macro-braces = [
{ name = "quote", brace = "{" },
{ name = "quote::quote", brace = "{" },
]

View File

@ -12,10 +12,12 @@ as_variant = "1.2.0"
assert_matches2 = "0.1.0" assert_matches2 = "0.1.0"
assign = "1.1.1" assign = "1.1.1"
base64 = "0.22.0" base64 = "0.22.0"
bytes = "1.0.1"
criterion = "0.5.0" criterion = "0.5.0"
http = "1.1.0" http = "1.1.0"
js_int = "0.2.2" js_int = "0.2.2"
maplit = "1.0.2" maplit = "1.0.2"
rand = "0.8.5"
ruma-appservice-api = { version = "0.10.0", path = "crates/ruma-appservice-api" } ruma-appservice-api = { version = "0.10.0", path = "crates/ruma-appservice-api" }
ruma-common = { version = "0.13.0", path = "crates/ruma-common" } ruma-common = { version = "0.13.0", path = "crates/ruma-common" }
ruma-client = { version = "0.13.0", path = "crates/ruma-client" } ruma-client = { version = "0.13.0", path = "crates/ruma-client" }

View File

@ -7,6 +7,9 @@ Breaking changes:
- 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 - Make `id_server` and `id_access_token` in `ThirdpartyIdCredentials` optional
- The `content_disposition` fields of `media::get_content::v3::Response` and
`media::get_content_as_filename::v3::Response` use now the strongly typed
`ContentDisposition` instead of strings.
Improvements: Improvements:
@ -31,6 +34,8 @@ Bug fixes:
- `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 - Make authentication with access token optional for the `change_password` and
`deactivate` endpoints. `deactivate` endpoints.
- Do not send a request body for the `logout` and `logout_all` endpoints, due
to a clarification in the spec.
# 0.18.0 # 0.18.0

View File

@ -55,7 +55,7 @@ unstable-msc4140 = []
[dependencies] [dependencies]
as_variant = { workspace = true } as_variant = { workspace = true }
assign = { workspace = true } assign = { workspace = true }
bytes = "1.0.1" bytes = { workspace = true }
date_header = "1.0.5" date_header = "1.0.5"
http = { workspace = true } http = { workspace = true }
js_int = { workspace = true, features = ["serde"] } js_int = { workspace = true, features = ["serde"] }

View File

@ -12,6 +12,7 @@ pub mod v1 {
use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName, metadata, IdParseError, MxcUri, OwnedServerName,
}; };
@ -43,8 +44,8 @@ pub mod v1 {
#[ruma_api(query)] #[ruma_api(query)]
#[serde( #[serde(
with = "ruma_common::serde::duration::ms", with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout", default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout" skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)] )]
pub timeout_ms: Duration, pub timeout_ms: Duration,
} }
@ -62,18 +63,18 @@ pub mod v1 {
/// The value of the `Content-Disposition` HTTP header, possibly containing the name of the /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
/// file that was previously uploaded. /// file that was previously uploaded.
///
/// See [MDN] for the syntax.
///
/// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
#[ruma_api(header = CONTENT_DISPOSITION)] #[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: Option<String>, pub content_disposition: Option<ContentDisposition>,
} }
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 {
Self { media_id, server_name, timeout_ms: crate::media::default_download_timeout() } Self {
media_id,
server_name,
timeout_ms: ruma_common::media::default_download_timeout(),
}
} }
/// Creates a new `Request` with the given URI. /// Creates a new `Request` with the given URI.

View File

@ -12,6 +12,7 @@ pub mod v1 {
use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName, metadata, IdParseError, MxcUri, OwnedServerName,
}; };
@ -47,8 +48,8 @@ pub mod v1 {
#[ruma_api(query)] #[ruma_api(query)]
#[serde( #[serde(
with = "ruma_common::serde::duration::ms", with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout", default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout" skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)] )]
pub timeout_ms: Duration, pub timeout_ms: Duration,
} }
@ -66,12 +67,8 @@ pub mod v1 {
/// The value of the `Content-Disposition` HTTP header, possibly containing the name of the /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
/// file that was previously uploaded. /// file that was previously uploaded.
///
/// See [MDN] for the syntax.
///
/// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
#[ruma_api(header = CONTENT_DISPOSITION)] #[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: Option<String>, pub content_disposition: Option<ContentDisposition>,
} }
impl Request { impl Request {
@ -81,7 +78,7 @@ pub mod v1 {
media_id, media_id,
server_name, server_name,
filename, filename,
timeout_ms: crate::media::default_download_timeout(), timeout_ms: ruma_common::media::default_download_timeout(),
} }
} }

View File

@ -13,11 +13,10 @@ pub mod v1 {
use js_int::UInt; use js_int::UInt;
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
media::Method,
metadata, IdParseError, MxcUri, OwnedServerName, metadata, IdParseError, MxcUri, OwnedServerName,
}; };
use crate::media::get_content_thumbnail::v3::Method;
const METADATA: Metadata = metadata! { const METADATA: Metadata = metadata! {
method: GET, method: GET,
rate_limited: true, rate_limited: true,
@ -63,8 +62,8 @@ pub mod v1 {
#[ruma_api(query)] #[ruma_api(query)]
#[serde( #[serde(
with = "ruma_common::serde::duration::ms", with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout", default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout" skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)] )]
pub timeout_ms: Duration, pub timeout_ms: Duration,
@ -105,7 +104,7 @@ pub mod v1 {
method: None, method: None,
width, width,
height, height,
timeout_ms: crate::media::default_download_timeout(), timeout_ms: ruma_common::media::default_download_timeout(),
animated: None, animated: None,
} }
} }

View File

@ -0,0 +1,37 @@
//! Endpoints for sending and interacting with delayed events.
pub mod delayed_message_event;
pub mod delayed_state_event;
pub mod update_delayed_event;
use serde::{Deserialize, Serialize};
use web_time::Duration;
/// The query parameters for a delayed event request.
/// It contains the `timeout` configuration for a delayed event.
///
/// ### Note:
///
/// This is an Enum since the following properties might be added:
///
/// The **Timeout** case might get an additional optional `delay_parent_id` property.
/// The optional parent id is used to create a secondary timeout.
/// In a delay group with two timeouts only one of them will ever be sent.
///
/// The **Action** case might be added:
/// Adds an additional action to a delay event without a timeout but requires a `delay_id` (of the
/// parent delay event). A possible matrix event that can be send as an alternative to the parent
/// delay.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(untagged)]
pub enum DelayParameters {
/// Sending a delayed event with a timeout. The response will contain a (server
/// generated) `delay_id` instead of an `event_id`.
Timeout {
/// The timeout duration for this delayed event.
#[serde(with = "ruma_common::serde::duration::ms")]
#[serde(rename = "org.matrix.msc4140.delay")]
timeout: Duration,
},
}

View File

@ -1,6 +1,6 @@
//! `PUT /_matrix/client/*/rooms/{roomId}/send_future/{eventType}/{txnId}` //! `PUT /_matrix/client/*/rooms/{roomId}/send/{eventType}/{txnId}`
//! //!
//! Send a future (a scheduled message) to a room. [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140) //! Send a delayed event (a scheduled message) to a room. [MSC4140](https://github.com/matrix-org/matrix-spec-proposals/pull/4140)
pub mod unstable { pub mod unstable {
//! `msc4140` ([MSC]) //! `msc4140` ([MSC])
@ -16,17 +16,18 @@ pub mod unstable {
use ruma_events::{AnyMessageLikeEventContent, MessageLikeEventContent, MessageLikeEventType}; use ruma_events::{AnyMessageLikeEventContent, MessageLikeEventContent, MessageLikeEventType};
use serde_json::value::to_raw_value as to_raw_json_value; use serde_json::value::to_raw_value as to_raw_json_value;
use crate::future::FutureParameters; use crate::delayed_events::DelayParameters;
const METADATA: Metadata = metadata! { const METADATA: Metadata = metadata! {
method: PUT, method: PUT,
rate_limited: false, rate_limited: false,
authentication: AccessToken, authentication: AccessToken,
history: { history: {
unstable => "/_matrix/client/unstable/org.matrix.msc4140/rooms/:room_id/send_future/:event_type/:txn_id", // We use the unstable prefix for the delay query parameter but the stable v3 endpoint.
unstable => "/_matrix/client/v3/rooms/:room_id/send/:event_type/:txn_id",
} }
}; };
/// Request type for the [`send_future_message_event`](crate::future::send_future_message_event) /// Request type for the [`delayed_message_event`](crate::delayed_events::delayed_message_event)
/// endpoint. /// endpoint.
#[request(error = crate::Error)] #[request(error = crate::Error)]
pub struct Request { pub struct Request {
@ -50,12 +51,9 @@ pub mod unstable {
#[ruma_api(path)] #[ruma_api(path)]
pub txn_id: OwnedTransactionId, pub txn_id: OwnedTransactionId,
/// Additional parameters to describe sending a future. /// The timeout duration for this delayed event.
///
/// Only three combinations for `future_timeout` and `future_group_id` are possible.
/// The enum [`FutureParameters`] enforces this.
#[ruma_api(query_all)] #[ruma_api(query_all)]
pub future_parameters: FutureParameters, pub delay_parameters: DelayParameters,
/// The event content to send. /// The event content to send.
#[ruma_api(body)] #[ruma_api(body)]
@ -63,26 +61,15 @@ pub mod unstable {
} }
/// Response type for the /// Response type for the
/// [`send_future_message_event`](crate::future::send_future_message_event) endpoint. /// [`delayed_message_event`](crate::delayed_events::delayed_message_event) endpoint.
#[response(error = crate::Error)] #[response(error = crate::Error)]
pub struct Response { pub struct Response {
/// A token to send/insert the future into the DAG. /// The `delay_id` generated for this delayed event. Used to interact with delayed events.
pub send_token: String, pub delay_id: 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 { impl Request {
/// Creates a new `Request` with the given room id, transaction id future_parameters and /// Creates a new `Request` with the given room id, transaction id, `delay_parameters` and
/// event content. /// event content.
/// ///
/// # Errors /// # Errors
@ -92,7 +79,7 @@ pub mod unstable {
pub fn new<T>( pub fn new<T>(
room_id: OwnedRoomId, room_id: OwnedRoomId,
txn_id: OwnedTransactionId, txn_id: OwnedTransactionId,
future_parameters: FutureParameters, delay_parameters: DelayParameters,
content: &T, content: &T,
) -> serde_json::Result<Self> ) -> serde_json::Result<Self>
where where
@ -102,34 +89,29 @@ pub mod unstable {
room_id, room_id,
txn_id, txn_id,
event_type: content.event_type(), event_type: content.event_type(),
future_parameters, delay_parameters,
body: Raw::from_json(to_raw_json_value(content)?), body: Raw::from_json(to_raw_json_value(content)?),
}) })
} }
/// Creates a new `Request` with the given room id, transaction id, event type, /// Creates a new `Request` with the given room id, transaction id, event type,
/// future parameters and raw event content. /// `delay_parameters` and raw event content.
pub fn new_raw( pub fn new_raw(
room_id: OwnedRoomId, room_id: OwnedRoomId,
txn_id: OwnedTransactionId, txn_id: OwnedTransactionId,
event_type: MessageLikeEventType, event_type: MessageLikeEventType,
future_parameters: FutureParameters, delay_parameters: DelayParameters,
body: Raw<AnyMessageLikeEventContent>, body: Raw<AnyMessageLikeEventContent>,
) -> Self { ) -> Self {
Self { room_id, event_type, txn_id, future_parameters, body } Self { room_id, event_type, txn_id, delay_parameters, body }
} }
} }
impl Response { impl Response {
/// Creates a new `Response` with the tokens required to control the future using the /// Creates a new `Response` with the tokens required to control the delayed event using the
/// [`crate::future::update_future::unstable::Request`] request. /// [`crate::delayed_events::update_delayed_event::unstable::Request`] request.
pub fn new( pub fn new(delay_id: String) -> Self {
send_token: String, Self { delay_id }
cancel_token: String,
future_group_id: String,
refresh_token: Option<String>,
) -> Self {
Self { send_token, cancel_token, future_group_id, refresh_token }
} }
} }
@ -144,19 +126,16 @@ pub mod unstable {
use web_time::Duration; use web_time::Duration;
use super::Request; use super::Request;
use crate::future::send_future_message_event::unstable::FutureParameters; use crate::delayed_events::delayed_message_event::unstable::DelayParameters;
#[test] #[test]
fn serialize_message_future_request() { fn serialize_delayed_message_request() {
let room_id = owned_room_id!("!roomid:example.org"); let room_id = owned_room_id!("!roomid:example.org");
let req = Request::new( let req = Request::new(
room_id, room_id,
"1234".into(), "1234".into(),
FutureParameters::Timeout { DelayParameters::Timeout { timeout: Duration::from_millis(103) },
timeout: Duration::from_millis(103),
group_id: Some("testId".to_owned()),
},
&RoomMessageEventContent::text_plain("test"), &RoomMessageEventContent::text_plain("test"),
) )
.unwrap(); .unwrap();
@ -169,7 +148,7 @@ pub mod unstable {
.unwrap(); .unwrap();
let (parts, body) = request.into_parts(); let (parts, body) = request.into_parts();
assert_eq!( 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", "https://homeserver.tld/_matrix/client/v3/rooms/!roomid:example.org/send/m.room.message/1234?org.matrix.msc4140.delay=103",
parts.uri.to_string() parts.uri.to_string()
); );
assert_eq!("PUT", parts.method.to_string()); assert_eq!("PUT", parts.method.to_string());

View File

@ -0,0 +1,162 @@
//! `PUT /_matrix/client/*/rooms/{roomId}/state/{eventType}/{txnId}`
//!
//! Send a delayed state event (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::delayed_events::DelayParameters;
const METADATA: Metadata = metadata! {
method: PUT,
rate_limited: false,
authentication: AccessToken,
history: {
// We use the unstable prefix for the delay query parameter but the stable v3 endpoint.
unstable => "/_matrix/client/v3/rooms/:room_id/state/:event_type/:state_key",
}
};
/// Request type for the [`delayed_state_event`](crate::delayed_events::delayed_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 delayed event.
///
/// Only three combinations for `timeout` and `delay_parent_id` are possible.
/// The enum [`DelayParameters`] enforces this.
#[ruma_api(query_all)]
pub delay_parameters: DelayParameters,
/// The event content to send.
#[ruma_api(body)]
pub body: Raw<AnyStateEventContent>,
}
/// Response type for the [`delayed_state_event`](crate::delayed_events::delayed_state_event)
/// endpoint.
#[response(error = crate::Error)]
pub struct Response {
/// The `delay_id` generated for this delayed event. Used to interact with delayed events.
pub delay_id: String,
}
impl Request {
/// Creates a new `Request` with the given room id, state_key delay_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,
delay_parameters: DelayParameters,
content: &T,
) -> serde_json::Result<Self>
where
T: StateEventContent,
{
Ok(Self {
room_id,
state_key,
event_type: content.event_type(),
delay_parameters,
body: Raw::from_json(to_raw_json_value(content)?),
})
}
/// Creates a new `Request` with the given room id, event type, state key,
/// delay parameters and raw event content.
pub fn new_raw(
room_id: OwnedRoomId,
state_key: String,
event_type: StateEventType,
delay_parameters: DelayParameters,
body: Raw<AnyStateEventContent>,
) -> Self {
Self { room_id, event_type, state_key, body, delay_parameters }
}
}
impl Response {
/// Creates a new `Response` with the tokens required to control the delayed event using the
/// [`crate::delayed_events::update_delayed_event::unstable::Request`] request.
pub fn new(delay_id: String) -> Self {
Self { delay_id }
}
}
#[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::delayed_events::DelayParameters;
fn create_delayed_event_request(
delay_parameters: DelayParameters,
) -> (http::request::Parts, Vec<u8>) {
Request::new(
owned_room_id!("!roomid:example.org"),
"@userAsStateKey:example.org".to_owned(),
delay_parameters,
&RoomTopicEventContent::new("my_topic".to_owned()),
)
.unwrap()
.try_into_http_request(
"https://homeserver.tld",
SendAccessToken::IfRequired("auth_tok"),
&[MatrixVersion::V1_1],
)
.unwrap()
.into_parts()
}
#[test]
fn serialize_delayed_state_request() {
let (parts, body) = create_delayed_event_request(DelayParameters::Timeout {
timeout: Duration::from_millis(1_234_321),
});
assert_eq!(
"https://homeserver.tld/_matrix/client/v3/rooms/!roomid:example.org/state/m.room.topic/@userAsStateKey:example.org?org.matrix.msc4140.delay=1234321",
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,104 @@
//! `POST /_matrix/client/*/delayed_events/{delayed_id}`
//!
//! Send a delayed event update. This can be a updateing/canceling/sending the associated delayed
//! 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,
serde::StringEnum,
};
use crate::PrivOwnedStr;
const METADATA: Metadata = metadata! {
method: POST,
rate_limited: true,
authentication: AccessToken,
history: {
unstable => "/_matrix/client/unstable/org.matrix.msc4140/delayed_events/:delay_id",
}
};
/// The possible update actions we can do for updating a delayed event.
#[derive(Clone, StringEnum)]
#[ruma_enum(rename_all = "lowercase")]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum UpdateAction {
/// Restart the delayed event timeout. (heartbeat ping)
Restart,
/// Send the delayed event immediately independent of the timeout state. (deletes all
/// timers)
Send,
/// Delete the delayed event and never send it. (deletes all timers)
Cancel,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
/// Request type for the [`update_delayed_event`](crate::delayed_events::update_delayed_event)
/// endpoint.
#[request(error = crate::Error)]
pub struct Request {
/// The delay id that we want to update.
#[ruma_api(path)]
pub delay_id: String,
/// Which kind of update we want to request for the delayed event.
pub action: UpdateAction,
}
impl Request {
/// Creates a new `Request` to update a delayed event.
pub fn new(delay_id: String, action: UpdateAction) -> Self {
Self { delay_id, action }
}
}
/// Response type for the [`update_delayed_event`](crate::delayed_events::update_delayed_event)
/// endpoint.
#[response(error = crate::Error)]
pub struct Response {}
impl Response {
/// Creates a new empty response for the
/// [`update_delayed_event`](crate::delayed_events::update_delayed_event) endpoint.
pub fn new() -> Self {
Self {}
}
}
#[cfg(all(test, feature = "client"))]
mod tests {
use ruma_common::api::{MatrixVersion, OutgoingRequest, SendAccessToken};
use serde_json::{json, Value as JsonValue};
use super::{Request, UpdateAction};
#[test]
fn serialize_update_delayed_event_request() {
let request: http::Request<Vec<u8>> =
Request::new("1234".to_owned(), UpdateAction::Cancel)
.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/delayed_events/1234",
parts.uri.to_string()
);
assert_eq!("POST", parts.method.to_string());
assert_eq!(
json!({"action": "cancel"}),
serde_json::from_str::<JsonValue>(std::str::from_utf8(&body).unwrap()).unwrap()
);
}
}
}

View File

@ -1,38 +0,0 @@
//! 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

@ -1,175 +0,0 @@
//! `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

@ -1,49 +0,0 @@
//! `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

@ -3,6 +3,10 @@
use http::{header::HeaderName, HeaderValue}; use http::{header::HeaderName, HeaderValue};
use ruma_common::api::error::{HeaderDeserializationError, HeaderSerializationError}; use ruma_common::api::error::{HeaderDeserializationError, HeaderSerializationError};
pub use ruma_common::http_headers::{
ContentDisposition, ContentDispositionParseError, ContentDispositionType, TokenString,
TokenStringParseError,
};
use web_time::{Duration, SystemTime, UNIX_EPOCH}; use web_time::{Duration, SystemTime, UNIX_EPOCH};
/// The [`Cross-Origin-Resource-Policy`] HTTP response header. /// The [`Cross-Origin-Resource-Policy`] HTTP response header.

View File

@ -18,13 +18,13 @@ pub mod config;
pub mod context; pub mod context;
#[cfg(feature = "unstable-msc3814")] #[cfg(feature = "unstable-msc3814")]
pub mod dehydrated_device; pub mod dehydrated_device;
#[cfg(feature = "unstable-msc4140")]
pub mod delayed_events;
pub mod device; pub mod device;
pub mod directory; 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

@ -1,7 +1,5 @@
//! Endpoints for the media repository. //! Endpoints for the media repository.
use std::time::Duration;
pub mod create_content; pub mod create_content;
pub mod create_content_async; pub mod create_content_async;
pub mod create_mxc_uri; pub mod create_mxc_uri;
@ -10,14 +8,3 @@ pub mod get_content_as_filename;
pub mod get_content_thumbnail; pub mod get_content_thumbnail;
pub mod get_media_config; pub mod get_media_config;
pub mod get_media_preview; pub mod get_media_preview;
/// The default duration that the client should be willing to wait to start receiving data.
pub(crate) fn default_download_timeout() -> Duration {
Duration::from_secs(20)
}
/// Whether the given duration is the default duration that the client should be willing to wait to
/// start receiving data.
pub(crate) fn is_default_download_timeout(timeout: &Duration) -> bool {
timeout.as_secs() == 20
}

View File

@ -12,6 +12,7 @@ pub mod v3 {
use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE}; use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE};
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName, metadata, IdParseError, MxcUri, OwnedServerName,
}; };
@ -60,8 +61,8 @@ pub mod v3 {
#[ruma_api(query)] #[ruma_api(query)]
#[serde( #[serde(
with = "ruma_common::serde::duration::ms", with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout", default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout" skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)] )]
pub timeout_ms: Duration, pub timeout_ms: Duration,
@ -87,12 +88,8 @@ pub mod v3 {
/// The value of the `Content-Disposition` HTTP header, possibly containing the name of the /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
/// file that was previously uploaded. /// file that was previously uploaded.
///
/// See [MDN] for the syntax.
///
/// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
#[ruma_api(header = CONTENT_DISPOSITION)] #[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: Option<String>, pub content_disposition: Option<ContentDisposition>,
/// The value of the `Cross-Origin-Resource-Policy` HTTP header. /// The value of the `Cross-Origin-Resource-Policy` HTTP header.
/// ///
@ -119,7 +116,7 @@ pub mod v3 {
media_id, media_id,
server_name, server_name,
allow_remote: true, allow_remote: true,
timeout_ms: crate::media::default_download_timeout(), timeout_ms: ruma_common::media::default_download_timeout(),
allow_redirect: false, allow_redirect: false,
} }
} }

View File

@ -12,6 +12,7 @@ pub mod v3 {
use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE}; use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE};
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName, metadata, IdParseError, MxcUri, OwnedServerName,
}; };
@ -64,8 +65,8 @@ pub mod v3 {
#[ruma_api(query)] #[ruma_api(query)]
#[serde( #[serde(
with = "ruma_common::serde::duration::ms", with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout", default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout" skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)] )]
pub timeout_ms: Duration, pub timeout_ms: Duration,
@ -91,12 +92,8 @@ pub mod v3 {
/// The value of the `Content-Disposition` HTTP header, possibly containing the name of the /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
/// file that was previously uploaded. /// file that was previously uploaded.
///
/// See [MDN] for the syntax.
///
/// [MDN]: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition#Syntax
#[ruma_api(header = CONTENT_DISPOSITION)] #[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: Option<String>, pub content_disposition: Option<ContentDisposition>,
/// The value of the `Cross-Origin-Resource-Policy` HTTP header. /// The value of the `Cross-Origin-Resource-Policy` HTTP header.
/// ///
@ -124,7 +121,7 @@ pub mod v3 {
server_name, server_name,
filename, filename,
allow_remote: true, allow_remote: true,
timeout_ms: crate::media::default_download_timeout(), timeout_ms: ruma_common::media::default_download_timeout(),
allow_redirect: false, allow_redirect: false,
} }
} }

View File

@ -11,14 +11,13 @@ pub mod v3 {
use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE}; use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE};
use js_int::UInt; use js_int::UInt;
pub use ruma_common::media::Method;
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{request, response, Metadata},
metadata, metadata, IdParseError, MxcUri, OwnedServerName,
serde::StringEnum,
IdParseError, MxcUri, OwnedServerName,
}; };
use crate::{http_headers::CROSS_ORIGIN_RESOURCE_POLICY, PrivOwnedStr}; use crate::http_headers::CROSS_ORIGIN_RESOURCE_POLICY;
const METADATA: Metadata = metadata! { const METADATA: Metadata = metadata! {
method: GET, method: GET,
@ -80,8 +79,8 @@ pub mod v3 {
#[ruma_api(query)] #[ruma_api(query)]
#[serde( #[serde(
with = "ruma_common::serde::duration::ms", with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout", default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout" skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)] )]
pub timeout_ms: Duration, pub timeout_ms: Duration,
@ -157,7 +156,7 @@ pub mod v3 {
width, width,
height, height,
allow_remote: true, allow_remote: true,
timeout_ms: crate::media::default_download_timeout(), timeout_ms: ruma_common::media::default_download_timeout(),
allow_redirect: false, allow_redirect: false,
animated: None, animated: None,
} }
@ -186,20 +185,4 @@ pub mod v3 {
} }
} }
} }
/// The desired resizing method.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, StringEnum)]
#[ruma_enum(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Method {
/// Crop the original to produce the requested image dimensions.
Crop,
/// Maintain the original aspect ratio of the source image.
Scale,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
} }

View File

@ -8,7 +8,7 @@ pub mod v3 {
//! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3logout //! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3logout
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{response, Metadata},
metadata, metadata,
}; };
@ -23,15 +23,10 @@ pub mod v3 {
}; };
/// Request type for the `logout` endpoint. /// Request type for the `logout` endpoint.
#[request(error = crate::Error)] #[derive(Debug, Clone, Default)]
#[derive(Default)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Request {} pub struct Request {}
/// Response type for the `logout` endpoint.
#[response(error = crate::Error)]
#[derive(Default)]
pub struct Response {}
impl Request { impl Request {
/// Creates an empty `Request`. /// Creates an empty `Request`.
pub fn new() -> Self { pub fn new() -> Self {
@ -39,6 +34,69 @@ pub mod v3 {
} }
} }
#[cfg(feature = "client")]
impl ruma_common::api::OutgoingRequest for Request {
type EndpointError = crate::Error;
type IncomingResponse = Response;
const METADATA: Metadata = METADATA;
fn try_into_http_request<T: Default + bytes::BufMut>(
self,
base_url: &str,
access_token: ruma_common::api::SendAccessToken<'_>,
considering_versions: &'_ [ruma_common::api::MatrixVersion],
) -> Result<http::Request<T>, ruma_common::api::error::IntoHttpError> {
let url = METADATA.make_endpoint_url(considering_versions, base_url, &[], "")?;
http::Request::builder()
.method(METADATA.method)
.uri(url)
.header(
http::header::AUTHORIZATION,
format!(
"Bearer {}",
access_token
.get_required_for_endpoint()
.ok_or(ruma_common::api::error::IntoHttpError::NeedsAuthentication)?,
),
)
.body(T::default())
.map_err(Into::into)
}
}
#[cfg(feature = "server")]
impl ruma_common::api::IncomingRequest for Request {
type EndpointError = crate::Error;
type OutgoingResponse = Response;
const METADATA: Metadata = METADATA;
fn try_from_http_request<B, S>(
request: http::Request<B>,
_path_args: &[S],
) -> Result<Self, ruma_common::api::error::FromHttpRequestError>
where
B: AsRef<[u8]>,
S: AsRef<str>,
{
if request.method() != METADATA.method {
return Err(ruma_common::api::error::FromHttpRequestError::MethodMismatch {
expected: METADATA.method,
received: request.method().clone(),
});
}
Ok(Self {})
}
}
/// Response type for the `logout` endpoint.
#[response(error = crate::Error)]
#[derive(Default)]
pub struct Response {}
impl Response { impl Response {
/// Creates an empty `Response`. /// Creates an empty `Response`.
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -8,7 +8,7 @@ pub mod v3 {
//! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3logoutall //! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3logoutall
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{response, Metadata},
metadata, metadata,
}; };
@ -23,15 +23,10 @@ pub mod v3 {
}; };
/// Request type for the `logout_all` endpoint. /// Request type for the `logout_all` endpoint.
#[request(error = crate::Error)] #[derive(Debug, Clone, Default)]
#[derive(Default)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Request {} pub struct Request {}
/// Response type for the `logout_all` endpoint.
#[response(error = crate::Error)]
#[derive(Default)]
pub struct Response {}
impl Request { impl Request {
/// Creates an empty `Request`. /// Creates an empty `Request`.
pub fn new() -> Self { pub fn new() -> Self {
@ -39,6 +34,69 @@ pub mod v3 {
} }
} }
#[cfg(feature = "client")]
impl ruma_common::api::OutgoingRequest for Request {
type EndpointError = crate::Error;
type IncomingResponse = Response;
const METADATA: Metadata = METADATA;
fn try_into_http_request<T: Default + bytes::BufMut>(
self,
base_url: &str,
access_token: ruma_common::api::SendAccessToken<'_>,
considering_versions: &'_ [ruma_common::api::MatrixVersion],
) -> Result<http::Request<T>, ruma_common::api::error::IntoHttpError> {
let url = METADATA.make_endpoint_url(considering_versions, base_url, &[], "")?;
http::Request::builder()
.method(METADATA.method)
.uri(url)
.header(
http::header::AUTHORIZATION,
format!(
"Bearer {}",
access_token
.get_required_for_endpoint()
.ok_or(ruma_common::api::error::IntoHttpError::NeedsAuthentication)?,
),
)
.body(T::default())
.map_err(Into::into)
}
}
#[cfg(feature = "server")]
impl ruma_common::api::IncomingRequest for Request {
type EndpointError = crate::Error;
type OutgoingResponse = Response;
const METADATA: Metadata = METADATA;
fn try_from_http_request<B, S>(
request: http::Request<B>,
_path_args: &[S],
) -> Result<Self, ruma_common::api::error::FromHttpRequestError>
where
B: AsRef<[u8]>,
S: AsRef<str>,
{
if request.method() != METADATA.method {
return Err(ruma_common::api::error::FromHttpRequestError::MethodMismatch {
expected: METADATA.method,
received: request.method().clone(),
});
}
Ok(Self {})
}
}
/// Response type for the `logout_all` endpoint.
#[response(error = crate::Error)]
#[derive(Default)]
pub struct Response {}
impl Response { impl Response {
/// Creates an empty `Response`. /// Creates an empty `Response`.
pub fn new() -> Self { pub fn new() -> Self {

View File

@ -33,7 +33,7 @@ reqwest-rustls-native-roots = ["reqwest", "reqwest?/rustls-tls-native-roots"]
as_variant = { workspace = true, optional = true } as_variant = { workspace = true, optional = true }
assign = { workspace = true } assign = { workspace = true }
async-stream = "0.3.0" async-stream = "0.3.0"
bytes = "1.0.1" bytes = { workspace = true }
futures-core = "0.3.8" futures-core = "0.3.8"
http = { workspace = true } http = { workspace = true }
http-body-util = { version = "0.1.1", optional = true } http-body-util = { version = "0.1.1", optional = true }

View File

@ -13,6 +13,8 @@ Breaking changes:
This allows to use a struct or enum as well as a map to represent the list of 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 query parameters. Note that the (de)serialization of the type used must work
with `serde_html_form`. with `serde_html_form`.
- The `header` attribute for the `request` and `response` macros accepts any
type that implements `ToString` and `FromStr`.
Improvements: Improvements:
@ -25,6 +27,7 @@ Improvements:
parameter is deprecated, according to MSC4126 / Matrix 1.11. parameter is deprecated, according to MSC4126 / Matrix 1.11.
- Constructing a Matrix URI for an event with a room alias is deprecated, - Constructing a Matrix URI for an event with a room alias is deprecated,
according to MSC4132 / Matrix 1.11 according to MSC4132 / Matrix 1.11
- Implement `Eq` and `PartialEq` for `Metadata`
# 0.13.0 # 0.13.0

View File

@ -59,7 +59,7 @@ compat-optional = []
[dependencies] [dependencies]
as_variant = { workspace = true } as_variant = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
bytes = "1.0.1" bytes = { workspace = true }
form_urlencoded = "1.0.0" form_urlencoded = "1.0.0"
getrandom = { version = "0.2.6", optional = true } getrandom = { version = "0.2.6", optional = true }
http = { workspace = true, optional = true } http = { workspace = true, optional = true }
@ -71,11 +71,8 @@ konst = { version = "0.3.5", default-features = false, features = [
"parsing", "parsing",
], optional = true } ], optional = true }
percent-encoding = "2.1.0" percent-encoding = "2.1.0"
rand = { version = "0.8.3", optional = true } rand = { workspace = true, optional = true }
regex = { version = "1.5.6", default-features = false, features = [ regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] }
"std",
"perf",
] }
ruma-identifiers-validation = { workspace = true } ruma-identifiers-validation = { workspace = true }
ruma-macros = { workspace = true } ruma-macros = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@ -113,10 +113,12 @@ macro_rules! metadata {
/// To declare which part of the request a field belongs to: /// To declare which part of the request 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 request. The value must implement `Display`. Generally this is a `String`. /// headers on the request. The value must implement `ToString` and `FromStr`. Generally this
/// The attribute value shown above as `HEADER_NAME` must be a `const` expression of the type /// is a `String`. The attribute value shown above as `HEADER_NAME` must be a `const`
/// `http::header::HeaderName`, like one of the constants from `http::header`, e.g. /// expression of the type `http::header::HeaderName`, like one of the constants from
/// `CONTENT_TYPE`. /// `http::header`, e.g. `CONTENT_TYPE`. During deserialization of the request, if the field
/// is an `Option` and parsing the header fails, the error will be ignored and the value will
/// be `None`.
/// * `#[ruma_api(path)]`: Fields with this attribute will be inserted into the matching path /// * `#[ruma_api(path)]`: Fields with this attribute will be inserted into the matching path
/// component of the request URL. If there are multiple of these fields, the order in which /// component of the request URL. If there are multiple of these fields, the order in which
/// 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.
@ -230,9 +232,11 @@ pub use ruma_macros::request;
/// To declare which part of the response 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 `ToString` and `FromStr`. Generally
/// `String`. The attribute value shown above as `HEADER_NAME` must be a header name constant /// this is a `String`. The attribute value shown above as `HEADER_NAME` must be a header
/// from `http::header`, e.g. `CONTENT_TYPE`. /// name constant from `http::header`, e.g. `CONTENT_TYPE`. During deserialization of the
/// response, if the field is an `Option` and parsing the header fails, the error will be
/// ignored and the value will be `None`.
/// * 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 response body type, or /// * `#[ruma_api(body)]`: Use this if multiple endpoints should share a response body type, or

View File

@ -243,6 +243,10 @@ pub enum DeserializationError {
/// Header value deserialization failed. /// Header value deserialization failed.
#[error(transparent)] #[error(transparent)]
Header(#[from] HeaderDeserializationError), Header(#[from] HeaderDeserializationError),
/// Deserialization of `multipart/mixed` response failed.
#[error(transparent)]
MultipartMixed(#[from] MultipartMixedDeserializationError),
} }
impl From<std::convert::Infallible> for DeserializationError { impl From<std::convert::Infallible> for DeserializationError {
@ -277,6 +281,10 @@ pub enum HeaderDeserializationError {
#[error("missing header `{0}`")] #[error("missing header `{0}`")]
MissingHeader(String), MissingHeader(String),
/// The given header failed to parse.
#[error("invalid header: {0}")]
InvalidHeader(Box<dyn std::error::Error + Send + Sync + 'static>),
/// A header was received with a unexpected value. /// A header was received with a unexpected value.
#[error( #[error(
"The {header} header was received with an unexpected value, \ "The {header} header was received with an unexpected value, \
@ -290,6 +298,42 @@ pub enum HeaderDeserializationError {
/// The value we instead received and rejected. /// The value we instead received and rejected.
unexpected: String, unexpected: String,
}, },
/// The `Content-Type` header for a `multipart/mixed` response is missing the `boundary`
/// attribute.
#[error(
"The `Content-Type` header for a `multipart/mixed` response is missing the `boundary` attribute"
)]
MissingMultipartBoundary,
}
/// An error when deserializing a `multipart/mixed` response.
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum MultipartMixedDeserializationError {
/// There were not the number of body parts that were expected.
#[error(
"multipart/mixed response does not have enough body parts, \
expected {expected}, found {found}"
)]
MissingBodyParts {
/// The number of body parts expected in the response.
expected: usize,
/// The number of body parts found in the received response.
found: usize,
},
/// The separator between the headers and the content of a body part is missing.
#[error("multipart/mixed body part is missing separator between headers and content")]
MissingBodyPartInnerSeparator,
/// The separator between a header's name and value is missing.
#[error("multipart/mixed body part header is missing separator between name and value")]
MissingHeaderSeparator,
/// A header failed to parse.
#[error("invalid multipart/mixed header: {0}")]
InvalidHeader(Box<dyn std::error::Error + Send + Sync + 'static>),
} }
/// An error that happens when Ruma cannot understand a Matrix version. /// An error that happens when Ruma cannot understand a Matrix version.

View File

@ -19,7 +19,7 @@ use super::{
use crate::{percent_encode::PATH_PERCENT_ENCODE_SET, serde::slice_to_buf, RoomVersionId}; use crate::{percent_encode::PATH_PERCENT_ENCODE_SET, serde::slice_to_buf, RoomVersionId};
/// Metadata about an API endpoint. /// Metadata about an API endpoint.
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_structs)] #[allow(clippy::exhaustive_structs)]
pub struct Metadata { pub struct Metadata {
/// The HTTP method used by this endpoint. /// The HTTP method used by this endpoint.
@ -141,7 +141,7 @@ impl Metadata {
/// versions stable and unstable. /// versions stable and unstable.
/// ///
/// The amount and positioning of path variables are the same over all path variants. /// The amount and positioning of path variables are the same over all path variants.
#[derive(Clone, Debug)] #[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_structs)] #[allow(clippy::exhaustive_structs)]
pub struct VersionHistory { pub struct VersionHistory {
/// A list of unstable paths over this endpoint's history. /// A list of unstable paths over this endpoint's history.

View File

@ -0,0 +1,104 @@
//! Helpers for HTTP headers.
use std::borrow::Cow;
mod content_disposition;
mod rfc8187;
pub use self::content_disposition::{
ContentDisposition, ContentDispositionParseError, ContentDispositionType, TokenString,
TokenStringParseError,
};
/// Whether the given byte is a [`token` char].
///
/// [`token` char]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
pub const fn is_tchar(b: u8) -> bool {
b.is_ascii_alphanumeric()
|| matches!(
b,
b'!' | b'#'
| b'$'
| b'%'
| b'&'
| b'\''
| b'*'
| b'+'
| b'-'
| b'.'
| b'^'
| b'_'
| b'`'
| b'|'
| b'~'
)
}
/// Whether the given bytes slice is a [`token`].
///
/// [`token`]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
pub fn is_token(bytes: &[u8]) -> bool {
bytes.iter().all(|b| is_tchar(*b))
}
/// Whether the given string is a [`token`].
///
/// [`token`]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.2
pub fn is_token_string(s: &str) -> bool {
is_token(s.as_bytes())
}
/// Whether the given char is a [visible US-ASCII char].
///
/// [visible US-ASCII char]: https://datatracker.ietf.org/doc/html/rfc5234#appendix-B.1
pub const fn is_vchar(c: char) -> bool {
matches!(c, '\x21'..='\x7E')
}
/// Whether the given char is in the US-ASCII character set and allowed inside a [quoted string].
///
/// Contrary to the definition of quoted strings, this doesn't allow `obs-text` characters, i.e.
/// non-US-ASCII characters, as we usually deal with UTF-8 strings rather than ISO-8859-1 strings.
///
/// [quoted string]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4
pub const fn is_ascii_string_quotable(c: char) -> bool {
is_vchar(c) || matches!(c, '\x09' | '\x20')
}
/// Remove characters that do not pass [`is_ascii_string_quotable()`] from the given string.
///
/// [quoted string]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4
pub fn sanitize_for_ascii_quoted_string(value: &str) -> Cow<'_, str> {
if value.chars().all(is_ascii_string_quotable) {
return Cow::Borrowed(value);
}
Cow::Owned(value.chars().filter(|c| is_ascii_string_quotable(*c)).collect())
}
/// If the US-ASCII field value does not contain only token chars, convert it to a [quoted string].
///
/// The string should be sanitized with [`sanitize_for_ascii_quoted_string()`] or should only
/// contain characters that pass [`is_ascii_string_quotable()`].
///
/// [quoted string]: https://datatracker.ietf.org/doc/html/rfc9110#section-5.6.4
pub fn quote_ascii_string_if_required(value: &str) -> Cow<'_, str> {
if !value.is_empty() && is_token_string(value) {
return Cow::Borrowed(value);
}
let value = value.replace('\\', r#"\\"#).replace('"', r#"\""#);
Cow::Owned(format!("\"{value}\""))
}
/// Removes the escape backslashes in the given string.
pub fn unescape_string(s: &str) -> String {
let mut is_escaped = false;
s.chars()
.filter(|c| {
is_escaped = *c == '\\' && !is_escaped;
!is_escaped
})
.collect()
}

View File

@ -0,0 +1,736 @@
//! Types to (de)serialize the `Content-Disposition` HTTP header.
use std::{fmt, ops::Deref, str::FromStr};
use ruma_macros::{
AsRefStr, AsStrAsRefStr, DebugAsRefStr, DisplayAsRefStr, OrdAsRefStr, PartialOrdAsRefStr,
};
use super::{
is_tchar, is_token, quote_ascii_string_if_required, rfc8187, sanitize_for_ascii_quoted_string,
unescape_string,
};
/// The value of a `Content-Disposition` HTTP header.
///
/// This implementation supports the `Content-Disposition` header format as defined for HTTP in [RFC
/// 6266].
///
/// The only supported parameter is `filename`. It is encoded or decoded as needed, using a quoted
/// string or the `ext-token = ext-value` format, with the encoding defined in [RFC 8187].
///
/// This implementation does not support serializing to the format defined for the
/// `multipart/form-data` content type in [RFC 7578]. It should however manage to parse the
/// disposition type and filename parameter of the body parts.
///
/// [RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
/// [RFC 7578]: https://datatracker.ietf.org/doc/html/rfc7578
#[derive(Debug, Clone, PartialEq, Eq, Default)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct ContentDisposition {
/// The disposition type.
pub disposition_type: ContentDispositionType,
/// The filename of the content.
pub filename: Option<String>,
}
impl ContentDisposition {
/// Creates a new `ContentDisposition` with the given disposition type.
pub fn new(disposition_type: ContentDispositionType) -> Self {
Self { disposition_type, filename: None }
}
/// Add the given filename to this `ContentDisposition`.
pub fn with_filename(mut self, filename: Option<String>) -> Self {
self.filename = filename;
self
}
}
impl fmt::Display for ContentDisposition {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.disposition_type)?;
if let Some(filename) = &self.filename {
if filename.is_ascii() {
// First, remove all non-quotable characters, that is control characters.
let filename = sanitize_for_ascii_quoted_string(filename);
// We can use the filename parameter.
write!(f, "; filename={}", quote_ascii_string_if_required(&filename))?;
} else {
// We need to use RFC 8187 encoding.
write!(f, "; filename*={}", rfc8187::encode(filename))?;
}
}
Ok(())
}
}
impl TryFrom<&[u8]> for ContentDisposition {
type Error = ContentDispositionParseError;
fn try_from(value: &[u8]) -> Result<Self, Self::Error> {
let mut pos = 0;
skip_ascii_whitespaces(value, &mut pos);
if pos == value.len() {
return Err(ContentDispositionParseError::MissingDispositionType);
}
let disposition_type_start = pos;
// Find the next whitespace or `;`.
while let Some(byte) = value.get(pos) {
if byte.is_ascii_whitespace() || *byte == b';' {
break;
}
pos += 1;
}
let disposition_type =
ContentDispositionType::try_from(&value[disposition_type_start..pos])?;
// The `filename*` parameter (`filename_ext` here) using UTF-8 encoding should be used, but
// it is likely to be after the `filename` parameter containing only ASCII
// characters if both are present.
let mut filename_ext = None;
let mut filename = None;
// Parse the parameters. We ignore parameters that fail to parse for maximum compatibility.
while pos != value.len() {
if let Some(param) = RawParam::parse_next(value, &mut pos) {
if param.name.eq_ignore_ascii_case(b"filename*") {
if let Some(value) = param.decode_value() {
filename_ext = Some(value);
// We can stop parsing, this is the only parameter that we need.
break;
}
} else if param.name.eq_ignore_ascii_case(b"filename") {
if let Some(value) = param.decode_value() {
filename = Some(value);
}
}
}
}
Ok(Self { disposition_type, filename: filename_ext.or(filename) })
}
}
impl FromStr for ContentDisposition {
type Err = ContentDispositionParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.as_bytes().try_into()
}
}
/// A raw parameter in a `Content-Disposition` HTTP header.
struct RawParam<'a> {
name: &'a [u8],
value: &'a [u8],
is_quoted_string: bool,
}
impl<'a> RawParam<'a> {
/// Parse the next `RawParam` in the given bytes, starting at the given position.
///
/// The position is updated during the parsing.
///
/// Returns `None` if no parameter was found or if an error occurred when parsing the
/// parameter.
fn parse_next(bytes: &'a [u8], pos: &mut usize) -> Option<Self> {
let name = parse_param_name(bytes, pos)?;
skip_ascii_whitespaces(bytes, pos);
if *pos == bytes.len() {
// We are at the end of the bytes and only have the parameter name.
return None;
}
if bytes[*pos] != b'=' {
// We should have an equal sign, there is a problem with the bytes and we can't recover
// from it.
// Skip to the end to stop the parsing.
*pos = bytes.len();
return None;
}
// Skip the equal sign.
*pos += 1;
skip_ascii_whitespaces(bytes, pos);
let (value, is_quoted_string) = parse_param_value(bytes, pos)?;
Some(Self { name, value, is_quoted_string })
}
/// Decode the value of this `RawParam`.
///
/// Returns `None` if decoding the param failed.
fn decode_value(&self) -> Option<String> {
if self.name.ends_with(b"*") {
rfc8187::decode(self.value).ok().map(|s| s.into_owned())
} else {
let s = String::from_utf8_lossy(self.value);
if self.is_quoted_string {
Some(unescape_string(&s))
} else {
Some(s.into_owned())
}
}
}
}
/// Skip ASCII whitespaces in the given bytes, starting at the given position.
///
/// The position is updated to after the whitespaces.
fn skip_ascii_whitespaces(bytes: &[u8], pos: &mut usize) {
while let Some(byte) = bytes.get(*pos) {
if !byte.is_ascii_whitespace() {
break;
}
*pos += 1;
}
}
/// Parse a parameter name in the given bytes, starting at the given position.
///
/// The position is updated while parsing.
///
/// Returns `None` if the end of the bytes was reached, or if an error was encountered.
fn parse_param_name<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<&'a [u8]> {
skip_ascii_whitespaces(bytes, pos);
if *pos == bytes.len() {
// We are at the end of the bytes and didn't find anything.
return None;
}
let name_start = *pos;
// Find the end of the parameter name. The name can only contain token chars.
while let Some(byte) = bytes.get(*pos) {
if !is_tchar(*byte) {
break;
}
*pos += 1;
}
if *pos == bytes.len() {
// We are at the end of the bytes and only have the parameter name.
return None;
}
if bytes[*pos] == b';' {
// We are at the end of the parameter and only have the parameter name, skip the `;` and
// parse the next parameter.
*pos += 1;
return None;
}
let name = &bytes[name_start..*pos];
if name.is_empty() {
// It's probably a syntax error, we cannot recover from it.
*pos = bytes.len();
return None;
}
Some(name)
}
/// Parse a parameter value in the given bytes, starting at the given position.
///
/// The position is updated while parsing.
///
/// Returns a `(value, is_quoted_string)` tuple if parsing succeeded.
/// Returns `None` if the end of the bytes was reached, or if an error was encountered.
fn parse_param_value<'a>(bytes: &'a [u8], pos: &mut usize) -> Option<(&'a [u8], bool)> {
skip_ascii_whitespaces(bytes, pos);
if *pos == bytes.len() {
// We are at the end of the bytes and didn't find anything.
return None;
}
let is_quoted_string = bytes[*pos] == b'"';
if is_quoted_string {
// Skip the start double quote.
*pos += 1;
}
let value_start = *pos;
// Keep track of whether the next byte is escaped with a backslash.
let mut escape_next = false;
// Find the end of the value, it's a whitespace or a semi-colon, or a double quote if the string
// is quoted.
while let Some(byte) = bytes.get(*pos) {
if !is_quoted_string && (byte.is_ascii_whitespace() || *byte == b';') {
break;
}
if is_quoted_string && *byte == b'"' && !escape_next {
break;
}
escape_next = *byte == b'\\' && !escape_next;
*pos += 1;
}
let value = &bytes[value_start..*pos];
if is_quoted_string && *pos != bytes.len() {
// Skip the end double quote.
*pos += 1;
}
skip_ascii_whitespaces(bytes, pos);
// Check for parameters separator if we are not at the end of the string.
if *pos != bytes.len() {
if bytes[*pos] == b';' {
// Skip the `;` at the end of the parameter.
*pos += 1;
} else {
// We should have a `;`, there is a problem with the bytes and we can't recover
// from it.
// Skip to the end to stop the parsing.
*pos = bytes.len();
return None;
}
}
Some((value, is_quoted_string))
}
/// An error encountered when trying to parse an invalid [`ContentDisposition`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ContentDispositionParseError {
/// The disposition type is missing.
#[error("disposition type is missing")]
MissingDispositionType,
/// The disposition type is invalid.
#[error("invalid disposition type: {0}")]
InvalidDispositionType(#[from] TokenStringParseError),
}
/// A disposition type in the `Content-Disposition` HTTP header as defined in [Section 4.2 of RFC
/// 6266].
///
/// This type can hold an arbitrary [`TokenString`]. To build this with a custom value, convert it
/// from a `TokenString` with `::from()` / `.into()`. To check for values that are not available as
/// a documented variant here, use its string representation, obtained through
/// [`.as_str()`](Self::as_str()).
///
/// Comparisons with other string types are done case-insensitively.
///
/// [Section 4.2 of RFC 6266]: https://datatracker.ietf.org/doc/html/rfc6266#section-4.2
#[derive(
Clone,
Default,
AsRefStr,
DebugAsRefStr,
AsStrAsRefStr,
DisplayAsRefStr,
PartialOrdAsRefStr,
OrdAsRefStr,
)]
#[ruma_enum(rename_all = "lowercase")]
#[non_exhaustive]
pub enum ContentDispositionType {
/// The content can be displayed.
///
/// This is the default.
#[default]
Inline,
/// The content should be downloaded instead of displayed.
Attachment,
#[doc(hidden)]
_Custom(TokenString),
}
impl ContentDispositionType {
/// Try parsing a `&str` into a `ContentDispositionType`.
pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
Self::from_str(s)
}
}
impl From<TokenString> for ContentDispositionType {
fn from(value: TokenString) -> Self {
if value.eq_ignore_ascii_case("inline") {
Self::Inline
} else if value.eq_ignore_ascii_case("attachment") {
Self::Attachment
} else {
Self::_Custom(value)
}
}
}
impl<'a> TryFrom<&'a [u8]> for ContentDispositionType {
type Error = TokenStringParseError;
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.eq_ignore_ascii_case(b"inline") {
Ok(Self::Inline)
} else if value.eq_ignore_ascii_case(b"attachment") {
Ok(Self::Attachment)
} else {
TokenString::try_from(value).map(Self::_Custom)
}
}
}
impl FromStr for ContentDispositionType {
type Err = TokenStringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.as_bytes().try_into()
}
}
impl PartialEq<ContentDispositionType> for ContentDispositionType {
fn eq(&self, other: &ContentDispositionType) -> bool {
self.as_str().eq_ignore_ascii_case(other.as_str())
}
}
impl Eq for ContentDispositionType {}
impl PartialEq<TokenString> for ContentDispositionType {
fn eq(&self, other: &TokenString) -> bool {
self.as_str().eq_ignore_ascii_case(other.as_str())
}
}
impl<'a> PartialEq<&'a str> for ContentDispositionType {
fn eq(&self, other: &&'a str) -> bool {
self.as_str().eq_ignore_ascii_case(other)
}
}
/// A non-empty string consisting only of `token`s as defined in [RFC 9110 Section 3.2.6].
///
/// This is a string that can only contain a limited character set.
///
/// [RFC 7230 Section 3.2.6]: https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.6
#[derive(
Clone,
PartialEq,
Eq,
DebugAsRefStr,
AsStrAsRefStr,
DisplayAsRefStr,
PartialOrdAsRefStr,
OrdAsRefStr,
)]
pub struct TokenString(Box<str>);
impl TokenString {
/// Try parsing a `&str` into a `TokenString`.
pub fn parse(s: &str) -> Result<Self, TokenStringParseError> {
Self::from_str(s)
}
}
impl Deref for TokenString {
type Target = str;
fn deref(&self) -> &Self::Target {
self.as_ref()
}
}
impl AsRef<str> for TokenString {
fn as_ref(&self) -> &str {
&self.0
}
}
impl<'a> PartialEq<&'a str> for TokenString {
fn eq(&self, other: &&'a str) -> bool {
self.as_str().eq(*other)
}
}
impl<'a> TryFrom<&'a [u8]> for TokenString {
type Error = TokenStringParseError;
fn try_from(value: &'a [u8]) -> Result<Self, Self::Error> {
if value.is_empty() {
Err(TokenStringParseError::Empty)
} else if is_token(value) {
let s = std::str::from_utf8(value).expect("ASCII bytes are valid UTF-8");
Ok(Self(s.into()))
} else {
Err(TokenStringParseError::InvalidCharacter)
}
}
}
impl FromStr for TokenString {
type Err = TokenStringParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
s.as_bytes().try_into()
}
}
/// The parsed string contains a character not allowed for a [`TokenString`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum TokenStringParseError {
/// The string is empty.
#[error("string is empty")]
Empty,
/// The string contains an invalid character for a token string.
#[error("string contains invalid character")]
InvalidCharacter,
}
#[cfg(test)]
mod tests {
use std::str::FromStr;
use super::{ContentDisposition, ContentDispositionType};
#[test]
fn parse_content_disposition_valid() {
// Only disposition type.
let content_disposition = ContentDisposition::from_str("inline").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename, None);
// Only disposition type with separator.
let content_disposition = ContentDisposition::from_str("attachment;").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename, None);
// Unknown disposition type and parameters.
let content_disposition =
ContentDisposition::from_str("custom; foo=bar; foo*=utf-8''b%C3%A0r'").unwrap();
assert_eq!(content_disposition.disposition_type.as_str(), "custom");
assert_eq!(content_disposition.filename, None);
// Disposition type and filename.
let content_disposition = ContentDisposition::from_str("inline; filename=my_file").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
// Case insensitive.
let content_disposition = ContentDisposition::from_str("INLINE; FILENAME=my_file").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
// Extra spaces.
let content_disposition =
ContentDisposition::from_str(" INLINE ;FILENAME = my_file ").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
// Unsupported filename* is skipped and falls back to ASCII filename.
let content_disposition = ContentDisposition::from_str(
r#"attachment; filename*=iso-8859-1''foo-%E4.html; filename="foo-a.html"#,
)
.unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.unwrap(), "foo-a.html");
// filename could be UTF-8 for extra compatibility (with `form-data` for example).
let content_disposition =
ContentDisposition::from_str(r#"form-data; name=upload; filename="文件.webp""#)
.unwrap();
assert_eq!(content_disposition.disposition_type.as_str(), "form-data");
assert_eq!(content_disposition.filename.unwrap(), "文件.webp");
}
#[test]
fn parse_content_disposition_invalid_type() {
// Empty.
ContentDisposition::from_str("").unwrap_err();
// Missing disposition type.
ContentDisposition::from_str("; foo=bar").unwrap_err();
}
#[test]
fn parse_content_disposition_invalid_parameters() {
// Unexpected `:` after parameter name, filename parameter is not reached.
let content_disposition =
ContentDisposition::from_str("inline; foo:bar; filename=my_file").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename, None);
// Same error, but after filename, so filename was parser.
let content_disposition =
ContentDisposition::from_str("inline; filename=my_file; foo:bar").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "my_file");
// Missing `;` between parameters, filename parameter is not parsed successfully.
let content_disposition =
ContentDisposition::from_str("inline; filename=my_file foo=bar").unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename, None);
}
#[test]
fn content_disposition_serialize() {
// Only disposition type.
let content_disposition = ContentDisposition::new(ContentDispositionType::Inline);
let serialized = content_disposition.to_string();
assert_eq!(serialized, "inline");
// Disposition type and ASCII filename without space.
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my_file".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, "attachment; filename=my_file");
// Disposition type and ASCII filename with space.
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my file".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, r#"attachment; filename="my file""#);
// Disposition type and ASCII filename with double quote and backslash.
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some(r#""my"\file"#.to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, r#"attachment; filename="\"my\"\\file""#);
// Disposition type and UTF-8 filename.
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("Mi Corazón".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, "attachment; filename*=utf-8''Mi%20Coraz%C3%B3n");
// Sanitized filename.
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my\r\nfile".to_owned()));
let serialized = content_disposition.to_string();
assert_eq!(serialized, "attachment; filename=myfile");
}
#[test]
fn rfc6266_examples() {
// Basic syntax with unquoted filename.
let unquoted = "Attachment; filename=example.html";
let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "example.html");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, "attachment; filename=example.html");
// With quoted filename, case insensitivity and extra whitespaces.
let quoted = r#"INLINE; FILENAME= "an example.html""#;
let content_disposition = ContentDisposition::from_str(quoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "an example.html");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"inline; filename="an example.html""#);
// With RFC 8187-encoded UTF-8 filename.
let rfc8187 = "attachment; filename*= UTF-8''%e2%82%ac%20rates";
let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20rates"#);
// With RFC 8187-encoded UTF-8 filename with fallback ASCII filename.
let rfc8187_with_fallback =
r#"attachment; filename="EURO rates"; filename*=utf-8''%e2%82%ac%20rates"#;
let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ rates");
}
#[test]
fn rfc8187_examples() {
// Those examples originate from RFC 8187, but are changed to fit the expectations here:
//
// - A disposition type is added
// - The title parameter is renamed to filename
// Basic syntax with unquoted filename.
let unquoted = "attachment; foo= bar; filename=Economy";
let content_disposition = ContentDisposition::from_str(unquoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "Economy");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, "attachment; filename=Economy");
// With quoted filename.
let quoted = r#"attachment; foo=bar; filename="US-$ rates""#;
let content_disposition = ContentDisposition::from_str(quoted).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "US-$ rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename="US-$ rates""#);
// With RFC 8187-encoded UTF-8 filename.
let rfc8187 = "attachment; foo=bar; filename*=utf-8'en'%C2%A3%20rates";
let content_disposition = ContentDisposition::from_str(rfc8187).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename*=utf-8''%C2%A3%20rates"#);
// With RFC 8187-encoded UTF-8 filename again.
let rfc8187_other =
r#"attachment; foo=bar; filename*=UTF-8''%c2%a3%20and%20%e2%82%ac%20rates"#;
let content_disposition = ContentDisposition::from_str(rfc8187_other).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "£ and € rates");
let reserialized = content_disposition.to_string();
assert_eq!(
reserialized,
r#"attachment; filename*=utf-8''%C2%A3%20and%20%E2%82%AC%20rates"#
);
// With RFC 8187-encoded UTF-8 filename with fallback ASCII filename.
let rfc8187_with_fallback = r#"attachment; foo=bar; filename="EURO exchange rates"; filename*=utf-8''%e2%82%ac%20exchange%20rates"#;
let content_disposition = ContentDisposition::from_str(rfc8187_with_fallback).unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.as_deref().unwrap(), "€ exchange rates");
let reserialized = content_disposition.to_string();
assert_eq!(reserialized, r#"attachment; filename*=utf-8''%E2%82%AC%20exchange%20rates"#);
}
}

View File

@ -0,0 +1,76 @@
//! Encoding and decoding functions according to [RFC 8187].
//!
//! [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
use std::borrow::Cow;
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC};
/// The characters to percent-encode according to the `attr-char` set.
const ATTR_CHAR: AsciiSet = NON_ALPHANUMERIC
.remove(b'!')
.remove(b'#')
.remove(b'$')
.remove(b'&')
.remove(b'+')
.remove(b'-')
.remove(b'.')
.remove(b'^')
.remove(b'_')
.remove(b'`')
.remove(b'|')
.remove(b'~');
/// Encode the given string according to [RFC 8187].
///
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
pub(super) fn encode(s: &str) -> String {
let encoded = percent_encoding::utf8_percent_encode(s, &ATTR_CHAR);
format!("utf-8''{encoded}")
}
/// Decode the given bytes according to [RFC 8187].
///
/// Only the UTF-8 character set is supported, all other character sets return an error.
///
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
pub(super) fn decode(bytes: &[u8]) -> Result<Cow<'_, str>, Rfc8187DecodeError> {
if bytes.is_empty() {
return Err(Rfc8187DecodeError::Empty);
}
let mut parts = bytes.split(|b| *b == b'\'');
let charset = parts.next().ok_or(Rfc8187DecodeError::WrongPartsCount)?;
let _lang = parts.next().ok_or(Rfc8187DecodeError::WrongPartsCount)?;
let encoded = parts.next().ok_or(Rfc8187DecodeError::WrongPartsCount)?;
if parts.next().is_some() {
return Err(Rfc8187DecodeError::WrongPartsCount);
}
if !charset.eq_ignore_ascii_case(b"utf-8") {
return Err(Rfc8187DecodeError::NotUtf8);
}
// For maximum compatibility, do a lossy conversion.
Ok(percent_encoding::percent_decode(encoded).decode_utf8_lossy())
}
/// All errors encountered when trying to decode a string according to [RFC 8187].
///
/// [RFC 8187]: https://datatracker.ietf.org/doc/html/rfc8187
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub(super) enum Rfc8187DecodeError {
/// The string is empty.
#[error("string is empty")]
Empty,
/// The string does not contain the right number of parts.
#[error("string does not contain the right number of parts")]
WrongPartsCount,
/// The character set is not UTF-8.
#[error("character set is not UTF-8")]
NotUtf8,
}

View File

@ -24,7 +24,9 @@ pub mod authentication;
pub mod canonical_json; pub mod canonical_json;
pub mod directory; pub mod directory;
pub mod encryption; pub mod encryption;
pub mod http_headers;
mod identifiers; mod identifiers;
pub mod media;
mod percent_encode; mod percent_encode;
pub mod power_levels; pub mod power_levels;
pub mod presence; pub mod presence;

View File

@ -0,0 +1,34 @@
//! Common types and functions for the [content repository].
//!
//! [content repository]: https://spec.matrix.org/latest/client-server-api/#content-repository
use std::time::Duration;
use crate::{serde::StringEnum, PrivOwnedStr};
/// The desired resizing method for a thumbnail.
#[doc = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/src/doc/string_enum.md"))]
#[derive(Clone, StringEnum)]
#[ruma_enum(rename_all = "snake_case")]
#[non_exhaustive]
pub enum Method {
/// Crop the original to produce the requested image dimensions.
Crop,
/// Maintain the original aspect ratio of the source image.
Scale,
#[doc(hidden)]
_Custom(PrivOwnedStr),
}
/// The default duration that the client should be willing to wait to start receiving data.
pub fn default_download_timeout() -> Duration {
Duration::from_secs(20)
}
/// Whether the given duration is the default duration that the client should be willing to wait to
/// start receiving data.
pub fn is_default_download_timeout(timeout: &Duration) -> bool {
timeout.as_secs() == 20
}

View File

@ -188,13 +188,13 @@ mod tests {
#[test] #[test]
fn serialize_string() { fn serialize_string() {
assert_eq!(to_json_value(&Action::Notify).unwrap(), json!("notify")); assert_eq!(to_json_value(Action::Notify).unwrap(), json!("notify"));
} }
#[test] #[test]
fn serialize_tweak_sound() { fn serialize_tweak_sound() {
assert_eq!( assert_eq!(
to_json_value(&Action::SetTweak(Tweak::Sound("default".into()))).unwrap(), to_json_value(Action::SetTweak(Tweak::Sound("default".into()))).unwrap(),
json!({ "set_tweak": "sound", "value": "default" }) json!({ "set_tweak": "sound", "value": "default" })
); );
} }
@ -202,12 +202,12 @@ mod tests {
#[test] #[test]
fn serialize_tweak_highlight() { fn serialize_tweak_highlight() {
assert_eq!( assert_eq!(
to_json_value(&Action::SetTweak(Tweak::Highlight(true))).unwrap(), to_json_value(Action::SetTweak(Tweak::Highlight(true))).unwrap(),
json!({ "set_tweak": "highlight" }) json!({ "set_tweak": "highlight" })
); );
assert_eq!( assert_eq!(
to_json_value(&Action::SetTweak(Tweak::Highlight(false))).unwrap(), to_json_value(Action::SetTweak(Tweak::Highlight(false))).unwrap(),
json!({ "set_tweak": "highlight", "value": false }) json!({ "set_tweak": "highlight", "value": false })
); );
} }

View File

@ -364,7 +364,7 @@ impl StrExt for str {
return false; return false;
} }
let has_wildcards = pattern.contains(|c| matches!(c, '?' | '*')); let has_wildcards = pattern.contains(['?', '*']);
if has_wildcards { if has_wildcards {
let mut chunks: Vec<String> = vec![]; let mut chunks: Vec<String> = vec![];

View File

@ -74,6 +74,7 @@ where
} }
pub use ruma_macros::{ pub use ruma_macros::{
AsRefStr, DebugAsRefStr, DeserializeFromCowStr, DisplayAsRefStr, FromString, OrdAsRefStr, AsRefStr, AsStrAsRefStr, DebugAsRefStr, DeserializeFromCowStr, DisplayAsRefStr, FromString,
PartialEqAsRefStr, PartialOrdAsRefStr, SerializeAsRefStr, StringEnum, _FakeDeriveSerde, OrdAsRefStr, PartialEqAsRefStr, PartialOrdAsRefStr, SerializeAsRefStr, StringEnum,
_FakeDeriveSerde,
}; };

View File

@ -7,6 +7,7 @@ mod header_override;
mod manual_endpoint_impl; mod manual_endpoint_impl;
mod no_fields; mod no_fields;
mod optional_headers; mod optional_headers;
mod required_headers;
mod ruma_api; mod ruma_api;
mod ruma_api_macros; mod ruma_api_macros;
mod status_override; mod status_override;

View File

@ -1,6 +1,11 @@
use http::header::LOCATION; use assert_matches2::assert_matches;
use http::header::{CONTENT_DISPOSITION, LOCATION};
use ruma_common::{ use ruma_common::{
api::{request, response, Metadata}, api::{
request, response, IncomingRequest, IncomingResponse, MatrixVersion, Metadata,
OutgoingRequest, OutgoingResponse, SendAccessToken,
},
http_headers::{ContentDisposition, ContentDispositionType},
metadata, metadata,
}; };
@ -13,16 +18,128 @@ const METADATA: Metadata = metadata! {
} }
}; };
/// Request type for the `no_fields` endpoint. /// Request type for the `optional_headers` endpoint.
#[request] #[request]
pub struct Request { pub struct Request {
#[ruma_api(header = LOCATION)] #[ruma_api(header = LOCATION)]
pub location: Option<String>, pub location: Option<String>,
#[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: Option<ContentDisposition>,
} }
/// Response type for the `no_fields` endpoint. /// Response type for the `optional_headers` endpoint.
#[response] #[response]
pub struct Response { pub struct Response {
#[ruma_api(header = LOCATION)] #[ruma_api(header = LOCATION)]
pub stuff: Option<String>, pub stuff: Option<String>,
#[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: Option<ContentDisposition>,
}
#[test]
fn request_serde_no_header() {
let req = Request { location: None, content_disposition: None };
let http_req = req
.clone()
.try_into_http_request::<Vec<u8>>(
"https://homeserver.tld",
SendAccessToken::None,
&[MatrixVersion::V1_1],
)
.unwrap();
assert_matches!(http_req.headers().get(LOCATION), None);
assert_matches!(http_req.headers().get(CONTENT_DISPOSITION), None);
let req2 = Request::try_from_http_request::<_, &str>(http_req, &[]).unwrap();
assert_eq!(req2.location, None);
assert_eq!(req2.content_disposition, None);
}
#[test]
fn request_serde_with_header() {
let location = "https://other.tld/page/";
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my_file".to_owned()));
let req = Request {
location: Some(location.to_owned()),
content_disposition: Some(content_disposition.clone()),
};
let mut http_req = req
.clone()
.try_into_http_request::<Vec<u8>>(
"https://homeserver.tld",
SendAccessToken::None,
&[MatrixVersion::V1_1],
)
.unwrap();
assert_matches!(http_req.headers().get(LOCATION), Some(_));
assert_matches!(http_req.headers().get(CONTENT_DISPOSITION), Some(_));
let req2 = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap();
assert_eq!(req2.location.unwrap(), location);
assert_eq!(req2.content_disposition.unwrap(), content_disposition);
// Try removing the headers.
http_req.headers_mut().remove(LOCATION).unwrap();
http_req.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
let req3 = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap();
assert_eq!(req3.location, None);
assert_eq!(req3.content_disposition, None);
// Try setting invalid header.
http_req.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
let req4 = Request::try_from_http_request::<_, &str>(http_req, &[]).unwrap();
assert_eq!(req4.location, None);
assert_eq!(req4.content_disposition, None);
}
#[test]
fn response_serde_no_header() {
let res = Response { stuff: None, content_disposition: None };
let http_res = res.clone().try_into_http_response::<Vec<u8>>().unwrap();
assert_matches!(http_res.headers().get(LOCATION), None);
assert_matches!(http_res.headers().get(CONTENT_DISPOSITION), None);
let res2 = Response::try_from_http_response(http_res).unwrap();
assert_eq!(res2.stuff, None);
assert_eq!(res2.content_disposition, None);
}
#[test]
fn response_serde_with_header() {
let location = "https://other.tld/page/";
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my_file".to_owned()));
let res = Response {
stuff: Some(location.to_owned()),
content_disposition: Some(content_disposition.clone()),
};
let mut http_res = res.clone().try_into_http_response::<Vec<u8>>().unwrap();
assert_matches!(http_res.headers().get(LOCATION), Some(_));
assert_matches!(http_res.headers().get(CONTENT_DISPOSITION), Some(_));
let res2 = Response::try_from_http_response(http_res.clone()).unwrap();
assert_eq!(res2.stuff.unwrap(), location);
assert_eq!(res2.content_disposition.unwrap(), content_disposition);
// Try removing the headers.
http_res.headers_mut().remove(LOCATION).unwrap();
http_res.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
let res3 = Response::try_from_http_response(http_res.clone()).unwrap();
assert_eq!(res3.stuff, None);
assert_eq!(res3.content_disposition, None);
// Try setting invalid header.
http_res.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
let res4 = Response::try_from_http_response(http_res).unwrap();
assert_eq!(res4.stuff, None);
assert_eq!(res4.content_disposition, None);
} }

View File

@ -0,0 +1,130 @@
use assert_matches2::assert_matches;
use http::header::{CONTENT_DISPOSITION, LOCATION};
use ruma_common::{
api::{
error::{
DeserializationError, FromHttpRequestError, FromHttpResponseError,
HeaderDeserializationError,
},
request, response, IncomingRequest, IncomingResponse, MatrixVersion, Metadata,
OutgoingRequest, OutgoingResponse, SendAccessToken,
},
http_headers::{ContentDisposition, ContentDispositionType},
metadata,
};
const METADATA: Metadata = metadata! {
method: GET,
rate_limited: false,
authentication: None,
history: {
unstable => "/_matrix/my/endpoint",
}
};
/// Request type for the `required_headers` endpoint.
#[request]
pub struct Request {
#[ruma_api(header = LOCATION)]
pub location: String,
#[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: ContentDisposition,
}
/// Response type for the `required_headers` endpoint.
#[response]
pub struct Response {
#[ruma_api(header = LOCATION)]
pub stuff: String,
#[ruma_api(header = CONTENT_DISPOSITION)]
pub content_disposition: ContentDisposition,
}
#[test]
fn request_serde() {
let location = "https://other.tld/page/";
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my_file".to_owned()));
let req =
Request { location: location.to_owned(), content_disposition: content_disposition.clone() };
let mut http_req = req
.clone()
.try_into_http_request::<Vec<u8>>(
"https://homeserver.tld",
SendAccessToken::None,
&[MatrixVersion::V1_1],
)
.unwrap();
assert_matches!(http_req.headers().get(LOCATION), Some(_));
assert_matches!(http_req.headers().get(CONTENT_DISPOSITION), Some(_));
let req2 = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap();
assert_eq!(req2.location, location);
assert_eq!(req2.content_disposition, content_disposition);
// Try removing the headers.
http_req.headers_mut().remove(LOCATION).unwrap();
http_req.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
let err = Request::try_from_http_request::<_, &str>(http_req.clone(), &[]).unwrap_err();
assert_matches!(
err,
FromHttpRequestError::Deserialization(DeserializationError::Header(
HeaderDeserializationError::MissingHeader(_)
))
);
// Try setting invalid header.
http_req.headers_mut().insert(LOCATION, location.try_into().unwrap());
http_req.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
let err = Request::try_from_http_request::<_, &str>(http_req, &[]).unwrap_err();
assert_matches!(
err,
FromHttpRequestError::Deserialization(DeserializationError::Header(
HeaderDeserializationError::InvalidHeader(_)
))
);
}
#[test]
fn response_serde() {
let location = "https://other.tld/page/";
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("my_file".to_owned()));
let res =
Response { stuff: location.to_owned(), content_disposition: content_disposition.clone() };
let mut http_res = res.clone().try_into_http_response::<Vec<u8>>().unwrap();
assert_matches!(http_res.headers().get(LOCATION), Some(_));
assert_matches!(http_res.headers().get(CONTENT_DISPOSITION), Some(_));
let res2 = Response::try_from_http_response(http_res.clone()).unwrap();
assert_eq!(res2.stuff, location);
assert_eq!(res2.content_disposition, content_disposition);
// Try removing the headers.
http_res.headers_mut().remove(LOCATION).unwrap();
http_res.headers_mut().remove(CONTENT_DISPOSITION).unwrap();
let err = Response::try_from_http_response(http_res.clone()).unwrap_err();
assert_matches!(
err,
FromHttpResponseError::Deserialization(DeserializationError::Header(
HeaderDeserializationError::MissingHeader(_)
))
);
// Try setting invalid header.
http_res.headers_mut().insert(LOCATION, location.try_into().unwrap());
http_res.headers_mut().insert(CONTENT_DISPOSITION, ";".try_into().unwrap());
let err = Response::try_from_http_response(http_res).unwrap_err();
assert_matches!(
err,
FromHttpResponseError::Deserialization(DeserializationError::Header(
HeaderDeserializationError::InvalidHeader(_)
))
);
}

View File

@ -76,6 +76,7 @@ serde_json = { workspace = true, features = ["raw_value"] }
thiserror = { workspace = true } thiserror = { workspace = true }
tracing = { workspace = true, features = ["attributes"] } tracing = { workspace = true, features = ["attributes"] }
url = { workspace = true } url = { workspace = true }
web-time = { workspace = true }
wildmatch = "2.0.0" wildmatch = "2.0.0"
# dev-dependencies can't be optional, so this is a regular dependency # dev-dependencies can't be optional, so this is a regular dependency

View File

@ -3,11 +3,10 @@
//! //!
//! [MSC3489]: https://github.com/matrix-org/matrix-spec-proposals/pull/3489 //! [MSC3489]: https://github.com/matrix-org/matrix-spec-proposals/pull/3489
use std::time::{Duration, SystemTime};
use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId}; use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId};
use ruma_macros::EventContent; use ruma_macros::EventContent;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use web_time::{Duration, SystemTime};
use crate::location::AssetContent; use crate::location::AssetContent;

View File

@ -9,7 +9,7 @@ mod member_data;
pub use focus::*; pub use focus::*;
pub use member_data::*; pub use member_data::*;
use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId}; use ruma_common::MilliSecondsSinceUnixEpoch;
use ruma_macros::{EventContent, StringEnum}; use ruma_macros::{EventContent, StringEnum};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -29,7 +29,7 @@ use crate::{
/// ///
/// This struct also exposes allows to call the methods from [`CallMemberEventContent`]. /// This struct also exposes allows to call the methods from [`CallMemberEventContent`].
#[derive(Clone, Debug, Serialize, Deserialize, EventContent, PartialEq)] #[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)] #[ruma_event(type = "org.matrix.msc3401.call.member", kind = State, state_key_type = String, custom_redacted, custom_possibly_redacted)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)] #[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
#[serde(untagged)] #[serde(untagged)]
pub enum CallMemberEventContent { pub enum CallMemberEventContent {
@ -174,7 +174,7 @@ impl RedactContent for CallMemberEventContent {
pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent; pub type PossiblyRedactedCallMemberEventContent = CallMemberEventContent;
impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent { impl PossiblyRedactedStateEventContent for PossiblyRedactedCallMemberEventContent {
type StateKey = OwnedUserId; type StateKey = String;
} }
/// The Redacted version of [`CallMemberEventContent`]. /// The Redacted version of [`CallMemberEventContent`].
@ -190,7 +190,7 @@ impl ruma_events::content::EventContent for RedactedCallMemberEventContent {
} }
impl RedactedStateEventContent for RedactedCallMemberEventContent { impl RedactedStateEventContent for RedactedCallMemberEventContent {
type StateKey = OwnedUserId; type StateKey = String;
} }
/// Legacy content with an array of memberships. See also: [`CallMemberEventContent`] /// Legacy content with an array of memberships. See also: [`CallMemberEventContent`]
@ -463,8 +463,8 @@ mod tests {
serde_json::to_string(&call_member_ev).unwrap() serde_json::to_string(&call_member_ev).unwrap()
); );
} }
#[test]
fn deserialize_member_event() { fn deserialize_member_event_helper(state_key: &str) {
let ev = json!({ let ev = json!({
"content":{ "content":{
"application": "m.call", "application": "m.call",
@ -488,7 +488,7 @@ mod tests {
"event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc", "event_id": "$3qfxjGYSu4sL25FtR0ep6vePOc",
"room_id": "!1234:example.org", "room_id": "!1234:example.org",
"sender": "@user:example.org", "sender": "@user:example.org",
"state_key":"@user:example.org", "state_key": state_key,
"unsigned":{ "unsigned":{
"age":10, "age":10,
"prev_content": {}, "prev_content": {},
@ -504,7 +504,7 @@ mod tests {
let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap(); let event_id = OwnedEventId::try_from("$3qfxjGYSu4sL25FtR0ep6vePOc").unwrap();
let sender = OwnedUserId::try_from("@user:example.org").unwrap(); let sender = OwnedUserId::try_from("@user:example.org").unwrap();
let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap(); let room_id = OwnedRoomId::try_from("!1234:example.org").unwrap();
assert_eq!(member_event.state_key, sender); assert_eq!(member_event.state_key, state_key);
assert_eq!(member_event.event_id, event_id); assert_eq!(member_event.event_id, event_id);
assert_eq!(member_event.sender, sender); assert_eq!(member_event.sender, sender);
assert_eq!(member_event.room_id, room_id); assert_eq!(member_event.room_id, room_id);
@ -539,6 +539,21 @@ mod tests {
// CallMemberEventContent::Empty { leave_reason: None }, relations: None }) // CallMemberEventContent::Empty { leave_reason: None }, relations: None })
} }
#[test]
fn deserialize_member_event() {
deserialize_member_event_helper("@user:example.org");
}
#[test]
fn deserialize_member_event_with_scoped_state_key_prefixed() {
deserialize_member_event_helper("_@user:example.org:THIS_DEVICE");
}
#[test]
fn deserialize_member_event_with_scoped_state_key_unprefixed() {
deserialize_member_event_helper("@user:example.org:THIS_DEVICE");
}
fn timestamps() -> (TS, TS, TS) { fn timestamps() -> (TS, TS, TS) {
let now = TS::now(); let now = TS::now();
let one_second_ago = let one_second_ago =

View File

@ -36,13 +36,13 @@ fn serialize_redacted_message_event_content() {
#[test] #[test]
fn serialize_empty_redacted_aliases_event_content() { fn serialize_empty_redacted_aliases_event_content() {
assert_eq!(to_json_value(&RedactedRoomAliasesEventContent::default()).unwrap(), json!({})); assert_eq!(to_json_value(RedactedRoomAliasesEventContent::default()).unwrap(), json!({}));
} }
#[test] #[test]
fn redacted_aliases_event_serialize_with_content() { fn redacted_aliases_event_serialize_with_content() {
let expected = json!({ "aliases": [] }); let expected = json!({ "aliases": [] });
let actual = to_json_value(&RedactedRoomAliasesEventContent::new_v1(vec![])).unwrap(); let actual = to_json_value(RedactedRoomAliasesEventContent::new_v1(vec![])).unwrap();
assert_eq!(actual, expected); assert_eq!(actual, expected);
} }

View File

@ -1,5 +1,9 @@
# [unreleased] # [unreleased]
Improvements:
- Add support for authenticated media endpoints, according to MSC3916 / Matrix 1.11
# 0.9.0 # 0.9.0
Breaking changes: Breaking changes:

View File

@ -19,8 +19,8 @@ all-features = true
# them to an empty string in deserialization. # them to an empty string in deserialization.
compat-empty-string-null = [] compat-empty-string-null = []
client = [] client = ["dep:httparse", "dep:memchr"]
server = [] server = ["dep:bytes", "dep:rand"]
unstable-exhaustive-types = [] unstable-exhaustive-types = []
unstable-msc2448 = [] unstable-msc2448 = []
unstable-msc3618 = [] unstable-msc3618 = []
@ -30,7 +30,13 @@ unstable-msc4125 = []
unstable-unspecified = [] unstable-unspecified = []
[dependencies] [dependencies]
bytes = { workspace = true, optional = true }
http = { workspace = true }
httparse = { version = "1.9.0", optional = true }
js_int = { workspace = true, features = ["serde"] } js_int = { workspace = true, features = ["serde"] }
memchr = { version = "2.7.0", optional = true }
mime = { version = "0.3.0" }
rand = { workspace = true, optional = true }
ruma-common = { workspace = true, features = ["api"] } ruma-common = { workspace = true, features = ["api"] }
ruma-events = { workspace = true } ruma-events = { workspace = true }
serde = { workspace = true } serde = { workspace = true }

View File

@ -0,0 +1,511 @@
//! Authenticated endpoints for the content repository, according to [MSC3916].
//!
//! [MSC3916]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
use ruma_common::http_headers::ContentDisposition;
use serde::{Deserialize, Serialize};
pub mod get_content;
pub mod get_content_thumbnail;
/// The `multipart/mixed` mime "essence".
const MULTIPART_MIXED: &str = "multipart/mixed";
/// The maximum number of headers to parse in a body part.
const MAX_HEADERS_COUNT: usize = 32;
/// The length of the generated boundary.
const GENERATED_BOUNDARY_LENGTH: usize = 30;
/// The metadata of a file from the content repository.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct ContentMetadata {}
impl ContentMetadata {
/// Creates a new empty `ContentMetadata`.
pub fn new() -> Self {
Self {}
}
}
/// A file from the content repository or the location where it can be found.
#[derive(Debug, Clone)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub enum FileOrLocation {
/// The content of the file.
File(Content),
/// The file is at the given URL.
Location(String),
}
/// The content of a file from the content repository.
#[derive(Debug, Clone)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Content {
/// The content of the file as bytes.
pub file: Vec<u8>,
/// The content type of the file that was previously uploaded.
pub content_type: Option<String>,
/// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
/// file that was previously uploaded.
pub content_disposition: Option<ContentDisposition>,
}
impl Content {
/// Creates a new `Content` with the given bytes.
pub fn new(file: Vec<u8>) -> Self {
Self { file, content_type: None, content_disposition: None }
}
}
/// Serialize the given metadata and content into a `http::Response` `multipart/mixed` body.
///
/// Returns a tuple containing the boundary used
#[cfg(feature = "server")]
fn try_into_multipart_mixed_response<T: Default + bytes::BufMut>(
metadata: &ContentMetadata,
content: &FileOrLocation,
) -> Result<http::Response<T>, ruma_common::api::error::IntoHttpError> {
use std::io::Write as _;
use rand::Rng as _;
let boundary = rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.map(char::from)
.take(GENERATED_BOUNDARY_LENGTH)
.collect::<String>();
let mut body_writer = T::default().writer();
// Add first boundary separator and header for the metadata.
let _ = write!(
body_writer,
"\r\n--{boundary}\r\n{}: {}\r\n\r\n",
http::header::CONTENT_TYPE,
mime::APPLICATION_JSON
);
// Add serialized metadata.
serde_json::to_writer(&mut body_writer, metadata)?;
// Add second boundary separator.
let _ = write!(body_writer, "\r\n--{boundary}\r\n");
// Add content.
match content {
FileOrLocation::File(content) => {
// Add headers.
let content_type =
content.content_type.as_deref().unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref());
let _ = write!(body_writer, "{}: {content_type}\r\n", http::header::CONTENT_TYPE);
if let Some(content_disposition) = &content.content_disposition {
let _ = write!(
body_writer,
"{}: {content_disposition}\r\n",
http::header::CONTENT_DISPOSITION
);
}
// Add empty line separator after headers.
let _ = body_writer.write_all(b"\r\n");
// Add bytes.
let _ = body_writer.write_all(&content.file);
}
FileOrLocation::Location(location) => {
// Only add location header and empty line separator.
let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION);
}
}
// Add final boundary.
let _ = write!(body_writer, "\r\n--{boundary}--");
let content_type = format!("{MULTIPART_MIXED}; boundary={boundary}");
let body = body_writer.into_inner();
Ok(http::Response::builder().header(http::header::CONTENT_TYPE, content_type).body(body)?)
}
/// Deserialize the given metadata and content from a `http::Response` with a `multipart/mixed`
/// body.
#[cfg(feature = "client")]
fn try_from_multipart_mixed_response<T: AsRef<[u8]>>(
http_response: http::Response<T>,
) -> Result<
(ContentMetadata, FileOrLocation),
ruma_common::api::error::FromHttpResponseError<ruma_common::api::error::MatrixError>,
> {
use ruma_common::api::error::{HeaderDeserializationError, MultipartMixedDeserializationError};
// First, get the boundary from the content type header.
let body_content_type = http_response
.headers()
.get(http::header::CONTENT_TYPE)
.ok_or_else(|| HeaderDeserializationError::MissingHeader("Content-Type".to_owned()))?
.to_str()?
.parse::<mime::Mime>()
.map_err(|e| HeaderDeserializationError::InvalidHeader(e.into()))?;
if !body_content_type.essence_str().eq_ignore_ascii_case(MULTIPART_MIXED) {
return Err(HeaderDeserializationError::InvalidHeaderValue {
header: "Content-Type".to_owned(),
expected: MULTIPART_MIXED.to_owned(),
unexpected: body_content_type.essence_str().to_owned(),
}
.into());
}
let boundary = body_content_type
.get_param("boundary")
.ok_or(HeaderDeserializationError::MissingMultipartBoundary)?
.as_str()
.as_bytes();
// Split the body with the boundary.
let body = http_response.body().as_ref();
let mut full_boundary = Vec::with_capacity(boundary.len() + 4);
full_boundary.extend_from_slice(b"\r\n--");
full_boundary.extend_from_slice(boundary);
let mut boundaries = memchr::memmem::find_iter(body, &full_boundary);
let metadata_start = boundaries.next().ok_or_else(|| {
MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
})? + full_boundary.len();
let metadata_end = boundaries.next().ok_or_else(|| {
MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
})?;
let (_raw_metadata_headers, serialized_metadata) =
parse_multipart_body_part(body, metadata_start, metadata_end)?;
// Don't search for anything in the headers, just deserialize the content that should be JSON.
let metadata = serde_json::from_slice(serialized_metadata)?;
// Look at the part containing the media content now.
let content_start = metadata_end + full_boundary.len();
let content_end = boundaries.next().ok_or_else(|| {
MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 1 }
})?;
let (raw_content_headers, file) = parse_multipart_body_part(body, content_start, content_end)?;
// Parse the headers to retrieve the content type and content disposition.
let mut content_headers = [httparse::EMPTY_HEADER; MAX_HEADERS_COUNT];
httparse::parse_headers(raw_content_headers, &mut content_headers)
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?;
let mut location = None;
let mut content_type = None;
let mut content_disposition = None;
for header in content_headers {
if header.name.is_empty() {
// This is a empty header, we have reached the end of the parsed headers.
break;
}
if header.name == http::header::LOCATION {
location = Some(
String::from_utf8(header.value.to_vec())
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
);
// This is the only header we need, stop parsing.
break;
} else if header.name == http::header::CONTENT_TYPE {
content_type = Some(
String::from_utf8(header.value.to_vec())
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
);
} else if header.name == http::header::CONTENT_DISPOSITION {
content_disposition = Some(
ContentDisposition::try_from(header.value)
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
);
}
}
let content = if let Some(location) = location {
FileOrLocation::Location(location)
} else {
FileOrLocation::File(Content { file: file.to_owned(), content_type, content_disposition })
};
Ok((metadata, content))
}
/// Parse the multipart body part in the given bytes, starting and ending at the given positions.
///
/// Returns a `(headers_bytes, content_bytes)` tuple. Returns an error if the separation between the
/// headers and the content could not be found.
#[cfg(feature = "client")]
fn parse_multipart_body_part(
bytes: &[u8],
start: usize,
end: usize,
) -> Result<(&[u8], &[u8]), ruma_common::api::error::MultipartMixedDeserializationError> {
use ruma_common::api::error::MultipartMixedDeserializationError;
// The part should start with a newline after the boundary. We need to ignore characters before
// it in case of extra whitespaces, and for compatibility it might not have a CR.
let headers_start = memchr::memchr(b'\n', &bytes[start..end])
.expect("the end boundary contains a newline")
+ start
+ 1;
// Let's find an empty line now.
let mut line_start = headers_start;
let mut line_end;
loop {
line_end = memchr::memchr(b'\n', &bytes[line_start..end])
.ok_or(MultipartMixedDeserializationError::MissingBodyPartInnerSeparator)?
+ line_start
+ 1;
if matches!(&bytes[line_start..line_end], b"\r\n" | b"\n") {
break;
}
line_start = line_end;
}
Ok((&bytes[headers_start..line_start], &bytes[line_end..end]))
}
#[cfg(all(test, feature = "client", feature = "server"))]
mod tests {
use assert_matches2::assert_matches;
use ruma_common::http_headers::{ContentDisposition, ContentDispositionType};
use super::{
try_from_multipart_mixed_response, try_into_multipart_mixed_response, Content,
ContentMetadata, FileOrLocation,
};
#[test]
fn multipart_mixed_content_ascii_filename_conversions() {
let file = "s⌽me UTF-8 Ťext".as_bytes();
let content_type = "text/plain";
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("filename.txt".to_owned()));
let outgoing_metadata = ContentMetadata::new();
let outgoing_content = FileOrLocation::File(Content {
file: file.to_vec(),
content_type: Some(content_type.to_owned()),
content_disposition: Some(content_disposition.clone()),
});
let response =
try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
.unwrap();
let (_incoming_metadata, incoming_content) =
try_from_multipart_mixed_response(response).unwrap();
assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
assert_eq!(incoming_content.file, file);
assert_eq!(incoming_content.content_type.unwrap(), content_type);
assert_eq!(incoming_content.content_disposition, Some(content_disposition));
}
#[test]
fn multipart_mixed_content_utf8_filename_conversions() {
let file = "s⌽me UTF-8 Ťext".as_bytes();
let content_type = "text/plain";
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
.with_filename(Some("fȈlƩnąmǝ.txt".to_owned()));
let outgoing_metadata = ContentMetadata::new();
let outgoing_content = FileOrLocation::File(Content {
file: file.to_vec(),
content_type: Some(content_type.to_owned()),
content_disposition: Some(content_disposition.clone()),
});
let response =
try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
.unwrap();
let (_incoming_metadata, incoming_content) =
try_from_multipart_mixed_response(response).unwrap();
assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
assert_eq!(incoming_content.file, file);
assert_eq!(incoming_content.content_type.unwrap(), content_type);
assert_eq!(incoming_content.content_disposition, Some(content_disposition));
}
#[test]
fn multipart_mixed_location_conversions() {
let location = "https://server.local/media/filename.txt";
let outgoing_metadata = ContentMetadata::new();
let outgoing_content = FileOrLocation::Location(location.to_owned());
let response =
try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
.unwrap();
let (_incoming_metadata, incoming_content) =
try_from_multipart_mixed_response(response).unwrap();
assert_matches!(incoming_content, FileOrLocation::Location(incoming_location));
assert_eq!(incoming_location, location);
}
#[test]
fn multipart_mixed_deserialize_invalid() {
// Missing boundary in headers.
let body = "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed")
.body(body)
.unwrap();
try_from_multipart_mixed_response(response).unwrap_err();
// Wrong boundary.
let body =
"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=012345")
.body(body)
.unwrap();
try_from_multipart_mixed_response(response).unwrap_err();
// Missing boundary in body.
let body =
"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
try_from_multipart_mixed_response(response).unwrap_err();
// Missing header and content empty line separator in body part.
let body =
"\r\n--abcdef\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
try_from_multipart_mixed_response(response).unwrap_err();
// Control character in header.
let body =
"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\nContent-Disposition: inline; filename=\"my\nfile\"\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
try_from_multipart_mixed_response(response).unwrap_err();
}
#[test]
fn multipart_mixed_deserialize_valid() {
// Simple.
let body =
"\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
assert_matches!(content, FileOrLocation::File(file_content));
assert_eq!(file_content.file, b"some plain text");
assert_eq!(file_content.content_type.unwrap(), "text/plain");
assert_eq!(file_content.content_disposition, None);
// Case-insensitive headers.
let body =
"\r\n--abcdef\r\nCONTENT-type: application/json\r\n\r\n{}\r\n--abcdef\r\nCONTENT-TYPE: text/plain\r\ncoNtenT-disPosItioN: attachment; filename=my_file.txt\r\n\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
assert_matches!(content, FileOrLocation::File(file_content));
assert_eq!(file_content.file, b"some plain text");
assert_eq!(file_content.content_type.unwrap(), "text/plain");
let content_disposition = file_content.content_disposition.unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
assert_eq!(content_disposition.filename.unwrap(), "my_file.txt");
// Extra whitespace.
let body =
" \r\n--abcdef\r\ncontent-type: application/json \r\n\r\n {} \r\n--abcdef\r\ncontent-type: text/plain \r\n\r\nsome plain text\r\n--abcdef-- ";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
assert_matches!(content, FileOrLocation::File(file_content));
assert_eq!(file_content.file, b"some plain text");
assert_eq!(file_content.content_type.unwrap(), "text/plain");
assert_eq!(file_content.content_disposition, None);
// Missing CR except in boundaries.
let body =
"\r\n--abcdef\ncontent-type: application/json\n\n{}\r\n--abcdef\ncontent-type: text/plain \n\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
assert_matches!(content, FileOrLocation::File(file_content));
assert_eq!(file_content.file, b"some plain text");
assert_eq!(file_content.content_type.unwrap(), "text/plain");
assert_eq!(file_content.content_disposition, None);
// No body part headers.
let body = "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
assert_matches!(content, FileOrLocation::File(file_content));
assert_eq!(file_content.file, b"some plain text");
assert_eq!(file_content.content_type, None);
assert_eq!(file_content.content_disposition, None);
// Raw UTF-8 filename (some kind of compatibility with multipart/form-data).
let body =
"\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\ncontent-disposition: inline; filename=\"ȵ⌾Ⱦԩ💈Ňɠ\"\r\n\r\nsome plain text\r\n--abcdef--";
let response = http::Response::builder()
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
.body(body)
.unwrap();
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
assert_matches!(content, FileOrLocation::File(file_content));
assert_eq!(file_content.file, b"some plain text");
assert_eq!(file_content.content_type.unwrap(), "text/plain");
let content_disposition = file_content.content_disposition.unwrap();
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
assert_eq!(content_disposition.filename.unwrap(), "ȵ⌾Ⱦԩ💈Ňɠ");
}
}

View File

@ -0,0 +1,107 @@
//! `GET /_matrix/federation/*/media/download/{mediaId}`
//!
//! Retrieve content from the media store.
pub mod v1 {
//! `/v1/` ([spec])
//!
//! [spec]: https://spec.matrix.org/latest/server-server-api/#get_matrixfederationv1mediadownloadmediaid
use std::time::Duration;
use ruma_common::{
api::{request, Metadata},
metadata,
};
use crate::authenticated_media::{ContentMetadata, FileOrLocation};
const METADATA: Metadata = metadata! {
method: GET,
rate_limited: true,
authentication: ServerSignatures,
history: {
unstable => "/_matrix/federation/unstable/org.matrix.msc3916.v2/media/download/:media_id",
1.11 => "/_matrix/federation/v1/media/download/:media_id",
}
};
/// Request type for the `get_content` endpoint.
#[request]
pub struct Request {
/// The media ID from the `mxc://` URI (the path component).
#[ruma_api(path)]
pub media_id: String,
/// The maximum duration that the client is willing to wait to start receiving data, in the
/// case that the content has not yet been uploaded.
///
/// The default value is 20 seconds.
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
pub timeout_ms: Duration,
}
impl Request {
/// Creates a new `Request` with the given media ID.
pub fn new(media_id: String) -> Self {
Self { media_id, timeout_ms: ruma_common::media::default_download_timeout() }
}
}
/// Response type for the `get_content` endpoint.
#[derive(Debug, Clone)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Response {
/// The metadata of the media.
pub metadata: ContentMetadata,
/// The content of the media.
pub content: FileOrLocation,
}
impl Response {
/// Creates a new `Response` with the given metadata and content.
pub fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self {
Self { metadata, content }
}
}
#[cfg(feature = "client")]
impl ruma_common::api::IncomingResponse for Response {
type EndpointError = ruma_common::api::error::MatrixError;
fn try_from_http_response<T: AsRef<[u8]>>(
http_response: http::Response<T>,
) -> Result<Self, ruma_common::api::error::FromHttpResponseError<Self::EndpointError>>
{
use ruma_common::api::EndpointError;
if http_response.status().as_u16() < 400 {
let (metadata, content) =
crate::authenticated_media::try_from_multipart_mixed_response(http_response)?;
Ok(Self { metadata, content })
} else {
Err(ruma_common::api::error::FromHttpResponseError::Server(
ruma_common::api::error::MatrixError::from_http_response(http_response),
))
}
}
}
#[cfg(feature = "server")]
impl ruma_common::api::OutgoingResponse for Response {
fn try_into_http_response<T: Default + bytes::BufMut>(
self,
) -> Result<http::Response<T>, ruma_common::api::error::IntoHttpError> {
crate::authenticated_media::try_into_multipart_mixed_response(
&self.metadata,
&self.content,
)
}
}
}

View File

@ -0,0 +1,143 @@
//! `GET /_matrix/federation/*/media/thumbnail/{mediaId}`
//!
//! Get a thumbnail of content from the media repository.
pub mod v1 {
//! `/v1/` ([spec])
//!
//! [spec]: https://spec.matrix.org/latest/server-server-api/#get_matrixfederationv1mediathumbnailmediaid
use std::time::Duration;
use js_int::UInt;
use ruma_common::{
api::{request, Metadata},
media::Method,
metadata,
};
use crate::authenticated_media::{ContentMetadata, FileOrLocation};
const METADATA: Metadata = metadata! {
method: GET,
rate_limited: true,
authentication: ServerSignatures,
history: {
unstable => "/_matrix/federation/unstable/org.matrix.msc3916.v2/media/thumbnail/:media_id",
1.11 => "/_matrix/federation/v1/media/thumbnail/:media_id",
}
};
/// Request type for the `get_content_thumbnail` endpoint.
#[request]
pub struct Request {
/// The media ID from the `mxc://` URI (the path component).
#[ruma_api(path)]
pub media_id: String,
/// The desired resizing method.
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub method: Option<Method>,
/// The *desired* width of the thumbnail.
///
/// The actual thumbnail may not match the size specified.
#[ruma_api(query)]
pub width: UInt,
/// The *desired* height of the thumbnail.
///
/// The actual thumbnail may not match the size specified.
#[ruma_api(query)]
pub height: UInt,
/// The maximum duration that the client is willing to wait to start receiving data, in the
/// case that the content has not yet been uploaded.
///
/// The default value is 20 seconds.
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
pub timeout_ms: Duration,
/// Whether the server should return an animated thumbnail.
///
/// When `Some(true)`, the server should return an animated thumbnail if possible and
/// supported. When `Some(false)`, the server must not return an animated
/// thumbnail. When `None`, the server should not return an animated thumbnail.
#[ruma_api(query)]
#[serde(skip_serializing_if = "Option::is_none")]
pub animated: Option<bool>,
}
impl Request {
/// Creates a new `Request` with the given media ID, desired thumbnail width
/// and desired thumbnail height.
pub fn new(media_id: String, width: UInt, height: UInt) -> Self {
Self {
media_id,
method: None,
width,
height,
timeout_ms: ruma_common::media::default_download_timeout(),
animated: None,
}
}
}
/// Response type for the `get_content_thumbnail` endpoint.
#[derive(Debug, Clone)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Response {
/// The metadata of the thumbnail.
pub metadata: ContentMetadata,
/// The content of the thumbnail.
pub content: FileOrLocation,
}
impl Response {
/// Creates a new `Response` with the given metadata and content.
pub fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self {
Self { metadata, content }
}
}
#[cfg(feature = "client")]
impl ruma_common::api::IncomingResponse for Response {
type EndpointError = ruma_common::api::error::MatrixError;
fn try_from_http_response<T: AsRef<[u8]>>(
http_response: http::Response<T>,
) -> Result<Self, ruma_common::api::error::FromHttpResponseError<Self::EndpointError>>
{
use ruma_common::api::EndpointError;
if http_response.status().as_u16() < 400 {
let (metadata, content) =
crate::authenticated_media::try_from_multipart_mixed_response(http_response)?;
Ok(Self { metadata, content })
} else {
Err(ruma_common::api::error::FromHttpResponseError::Server(
ruma_common::api::error::MatrixError::from_http_response(http_response),
))
}
}
}
#[cfg(feature = "server")]
impl ruma_common::api::OutgoingResponse for Response {
fn try_into_http_response<T: Default + bytes::BufMut>(
self,
) -> Result<http::Response<T>, ruma_common::api::error::IntoHttpError> {
crate::authenticated_media::try_into_multipart_mixed_response(
&self.metadata,
&self.content,
)
}
}
}

View File

@ -12,6 +12,7 @@ use std::fmt;
mod serde; mod serde;
pub mod authenticated_media;
pub mod authorization; pub mod authorization;
pub mod backfill; pub mod backfill;
pub mod device; pub mod device;

View File

@ -80,18 +80,38 @@ impl Request {
syn::Type::Path(syn::TypePath { syn::Type::Path(syn::TypePath {
path: syn::Path { segments, .. }, .. path: syn::Path { segments, .. }, ..
}) if segments.last().unwrap().ident == "Option" => { }) if segments.last().unwrap().ident == "Option" => {
(quote! { Some(str_value.to_owned()) }, quote! { None }) let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
args: option_args, ..
}) = &segments.last().unwrap().arguments else {
panic!("Option should use angle brackets");
};
let syn::GenericArgument::Type(field_type) = option_args.first().unwrap() else {
panic!("Option brackets should contain type");
};
(
quote! {
str_value.parse::<#field_type>().ok()
},
quote! { None }
)
}
_ => {
let field_type = &field.ty;
(
quote! {
str_value
.parse::<#field_type>()
.map_err(|e| #ruma_common::api::error::HeaderDeserializationError::InvalidHeader(e.into()))?
},
quote! {
return Err(
#ruma_common::api::error::HeaderDeserializationError::MissingHeader(
#header_name_string.into()
).into(),
)
},
)
} }
_ => (
quote! { str_value.to_owned() },
quote! {
return Err(
#ruma_common::api::error::HeaderDeserializationError::MissingHeader(
#header_name_string.into()
).into(),
)
},
),
}; };
let decl = quote! { let decl = quote! {

View File

@ -66,7 +66,7 @@ impl Request {
if let Some(header_val) = self.#field_name.as_ref() { if let Some(header_val) = self.#field_name.as_ref() {
req_headers.insert( req_headers.insert(
#header_name, #header_name,
#http::header::HeaderValue::from_str(header_val)?, #http::header::HeaderValue::from_str(&header_val.to_string())?,
); );
} }
} }
@ -74,7 +74,7 @@ impl Request {
_ => quote! { _ => quote! {
req_headers.insert( req_headers.insert(
#header_name, #header_name,
#http::header::HeaderValue::from_str(self.#field_name.as_ref())?, #http::header::HeaderValue::from_str(&self.#field_name.to_string())?,
); );
}, },
} }

View File

@ -56,24 +56,37 @@ impl Response {
Type::Path(syn::TypePath { Type::Path(syn::TypePath {
path: syn::Path { segments, .. }, .. path: syn::Path { segments, .. }, ..
}) if segments.last().unwrap().ident == "Option" => { }) if segments.last().unwrap().ident == "Option" => {
let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments {
args: option_args, ..
}) = &segments.last().unwrap().arguments else {
panic!("Option should use angle brackets");
};
let syn::GenericArgument::Type(field_type) = option_args.first().unwrap() else {
panic!("Option brackets should contain type");
};
quote! { quote! {
#( #cfg_attrs )* #( #cfg_attrs )*
#field_name: { #field_name: {
headers.remove(#header_name) headers.remove(#header_name)
.map(|h| h.to_str().map(|s| s.to_owned())) .and_then(|h| { h.to_str().ok()?.parse::<#field_type>().ok() })
.transpose()?
} }
} }
} }
_ => quote! { _ => {
#( #cfg_attrs )* let field_type = &field.ty;
#field_name: { quote! {
headers.remove(#header_name) #( #cfg_attrs )*
.expect("response missing expected header") #field_name: {
.to_str()? headers.remove(#header_name)
.to_owned() .ok_or_else(|| #ruma_common::api::error::HeaderDeserializationError::MissingHeader(
#header_name.to_string()
))?
.to_str()?
.parse::<#field_type>()
.map_err(|e| #ruma_common::api::error::HeaderDeserializationError::InvalidHeader(e.into()))?
}
} }
}, }
}; };
quote! { #optional_header } quote! { #optional_header }
} }

View File

@ -22,7 +22,7 @@ impl Response {
if let Some(header) = self.#field_name { if let Some(header) = self.#field_name {
headers.insert( headers.insert(
#header_name, #header_name,
header.parse()?, header.to_string().parse()?,
); );
} }
} }
@ -30,7 +30,7 @@ impl Response {
_ => quote! { _ => quote! {
headers.insert( headers.insert(
#header_name, #header_name,
self.#field_name.parse()?, self.#field_name.to_string().parse()?,
); );
}, },
} }

View File

@ -342,11 +342,7 @@ pub mod v1 {
// If a highlight tweak is given with no value, its value is defined to be // If a highlight tweak is given with no value, its value is defined to be
// true. // true.
"highlight" => { "highlight" => {
let highlight = if let Ok(highlight) = access.next_value() { let highlight = access.next_value().unwrap_or(true);
highlight
} else {
true
};
tweaks.push(Tweak::Highlight(highlight)); tweaks.push(Tweak::Highlight(highlight));
} }

View File

@ -1,11 +1,12 @@
//! Common types for implementing federation authorization. //! Common types for implementing federation authorization.
use std::{borrow::Cow, fmt, str::FromStr}; use std::{fmt, str::FromStr};
use headers::authorization::Credentials; use headers::authorization::Credentials;
use http::HeaderValue; use http::HeaderValue;
use http_auth::ChallengeParser; use http_auth::ChallengeParser;
use ruma_common::{ use ruma_common::{
http_headers::quote_ascii_string_if_required,
serde::{Base64, Base64DecodeError}, serde::{Base64, Base64DecodeError},
IdParseError, OwnedServerName, OwnedServerSigningKeyId, IdParseError, OwnedServerName, OwnedServerSigningKeyId,
}; };
@ -119,40 +120,19 @@ impl fmt::Debug for XMatrix {
} }
} }
/// Whether the given char is a [token char].
///
/// [token char]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.2
fn is_tchar(c: char) -> bool {
const TOKEN_CHARS: [char; 15] =
['!', '#', '$', '%', '&', '\'', '*', '+', '-', '.', '^', '_', '`', '|', '~'];
c.is_ascii_alphanumeric() || TOKEN_CHARS.contains(&c)
}
/// If the field value does not contain only token chars, convert it to a [quoted string].
///
/// [quoted string]: https://www.rfc-editor.org/rfc/rfc9110#section-5.6.4
fn escape_field_value(value: &str) -> Cow<'_, str> {
if !value.is_empty() && value.chars().all(is_tchar) {
return Cow::Borrowed(value);
}
let value = value.replace('\\', r#"\\"#).replace('"', r#"\""#);
Cow::Owned(format!("\"{value}\""))
}
impl fmt::Display for XMatrix { impl fmt::Display for XMatrix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self { origin, destination, key, sig } = self; let Self { origin, destination, key, sig } = self;
let origin = escape_field_value(origin.as_str()); let origin = quote_ascii_string_if_required(origin.as_str());
let key = escape_field_value(key.as_str()); let key = quote_ascii_string_if_required(key.as_str());
let sig = sig.encode(); let sig = sig.encode();
let sig = escape_field_value(&sig); let sig = quote_ascii_string_if_required(&sig);
write!(f, r#"{} "#, Self::SCHEME)?; write!(f, r#"{} "#, Self::SCHEME)?;
if let Some(destination) = destination { if let Some(destination) = destination {
let destination = escape_field_value(destination.as_str()); let destination = quote_ascii_string_if_required(destination.as_str());
write!(f, r#"destination={destination},"#)?; write!(f, r#"destination={destination},"#)?;
} }

View File

@ -23,7 +23,7 @@ unstable-exhaustive-types = []
base64 = { workspace = true } base64 = { workspace = true }
ed25519-dalek = { version = "2.0.0", features = ["pkcs8", "rand_core"] } ed25519-dalek = { version = "2.0.0", features = ["pkcs8", "rand_core"] }
pkcs8 = { version = "0.10.0", features = ["alloc"] } pkcs8 = { version = "0.10.0", features = ["alloc"] }
rand = { version = "0.8.5", features = ["getrandom"] } rand = { workspace = true, features = ["getrandom"] }
ruma-common = { workspace = true, features = ["canonical-json"] } ruma-common = { workspace = true, features = ["canonical-json"] }
serde_json = { workspace = true } serde_json = { workspace = true }
sha2 = "0.10.6" sha2 = "0.10.6"

View File

@ -40,7 +40,7 @@ static REFERENCE_HASH_FIELDS_TO_REMOVE: &[&str] = &["signatures", "unsigned"];
/// # Parameters /// # Parameters
/// ///
/// * entity_id: The identifier of the entity creating the signature. Generally this means a /// * entity_id: The identifier of the entity creating the signature. Generally this means a
/// homeserver, e.g. "example.com". /// homeserver, e.g. "example.com".
/// * key_pair: A cryptographic key pair used to sign the JSON. /// * key_pair: A cryptographic key pair used to sign the JSON.
/// * object: A JSON object to sign according and append a signature to. /// * object: A JSON object to sign according and append a signature to.
/// ///
@ -167,9 +167,9 @@ pub fn canonical_json(object: &CanonicalJsonObject) -> Result<String, Error> {
/// # Parameters /// # Parameters
/// ///
/// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. /// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys.
/// Generally, entity identifiers are server names — the host/IP/port of a homeserver (e.g. /// Generally, entity identifiers are server names — the host/IP/port of a homeserver (e.g.
/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. /// "example.com") for which a signature must be verified. Key identifiers for each server (e.g.
/// "ed25519:1") then map to their respective public keys. /// "ed25519:1") then map to their respective public keys.
/// * object: The JSON object that was signed. /// * object: The JSON object that was signed.
/// ///
/// # Errors /// # Errors
@ -355,7 +355,7 @@ pub fn reference_hash(
/// # Parameters /// # Parameters
/// ///
/// * entity_id: The identifier of the entity creating the signature. Generally this means a /// * entity_id: The identifier of the entity creating the signature. Generally this means a
/// homeserver, e.g. "example.com". /// homeserver, e.g. "example.com".
/// * key_pair: A cryptographic key pair used to sign the event. /// * key_pair: A cryptographic key pair used to sign the event.
/// * object: A JSON object to be hashed and signed according to the Matrix specification. /// * object: A JSON object to be hashed and signed according to the Matrix specification.
/// ///
@ -486,9 +486,9 @@ where
/// # Parameters /// # Parameters
/// ///
/// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. /// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys.
/// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g. /// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g.
/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. /// "example.com") for which a signature must be verified. Key identifiers for each server (e.g.
/// "ed25519:1") then map to their respective public keys. /// "ed25519:1") then map to their respective public keys.
/// * object: The JSON object of the event that was signed. /// * object: The JSON object of the event that was signed.
/// * version: Room version of the given event /// * version: Room version of the given event
/// ///

View File

@ -32,7 +32,7 @@ criterion = { workspace = true, optional = true }
[dev-dependencies] [dev-dependencies]
maplit = { workspace = true } maplit = { workspace = true }
rand = "0.8.3" rand = { workspace = true }
ruma-events = { workspace = true, features = ["unstable-pdu"] } ruma-events = { workspace = true, features = ["unstable-pdu"] }
tracing-subscriber = "0.3.16" tracing-subscriber = "0.3.16"

View File

@ -276,11 +276,8 @@ unstable-unspecified = [
"ruma-push-gateway-api?/unstable-unspecified", "ruma-push-gateway-api?/unstable-unspecified",
] ]
# Private feature, only used in test / benchmarking code # Private features, only used in test / benchmarking code
__ci = [ __unstable-mscs = [
"full",
"compat-upload-signatures",
"unstable-unspecified",
"unstable-msc1767", "unstable-msc1767",
"unstable-msc2409", "unstable-msc2409",
"unstable-msc2448", "unstable-msc2448",
@ -322,7 +319,13 @@ __ci = [
"unstable-msc4108", "unstable-msc4108",
"unstable-msc4121", "unstable-msc4121",
"unstable-msc4125", "unstable-msc4125",
"unstable-msc4140" "unstable-msc4140",
]
__ci = [
"full",
"compat-upload-signatures",
"__unstable-mscs",
"unstable-unspecified",
] ]
[dependencies] [dependencies]

View File

@ -1,4 +1,4 @@
[toolchain] [toolchain]
# Keep in sync with version in `xtask/src/ci.rs` and `.github/workflows/ci.yml` # Keep in sync with version in `xtask/src/ci.rs` and `.github/workflows/ci.yml`
channel = "nightly-2024-05-09" channel = "nightly-2024-07-29"
components = ["rustfmt", "clippy"] components = ["rustfmt", "clippy"]

View File

@ -60,7 +60,7 @@ pub enum CiCmd {
NightlyAll, NightlyAll,
/// Lint default features with clippy (nightly) /// Lint default features with clippy (nightly)
ClippyDefault, ClippyDefault,
/// Lint ruma-common with clippy on a wasm target (nightly) /// Lint client features with clippy on a wasm target (nightly)
ClippyWasm, ClippyWasm,
/// Lint almost all features with clippy (nightly) /// Lint almost all features with clippy (nightly)
ClippyAll, ClippyAll,
@ -289,17 +289,15 @@ impl CiTask {
.map_err(Into::into) .map_err(Into::into)
} }
/// Lint ruma-common with clippy with the nightly version and wasm target. /// Lint ruma with clippy with the nightly version and wasm target.
///
/// ruma-common is currently the only crate with wasm-specific code. If that changes, this
/// method should be updated.
fn clippy_wasm(&self) -> Result<()> { fn clippy_wasm(&self) -> Result<()> {
cmd!( cmd!(
" "
rustup run {NIGHTLY} cargo clippy --target wasm32-unknown-unknown rustup run {NIGHTLY} cargo clippy --target wasm32-unknown-unknown -p ruma --features
-p ruma-common --features api,js,rand __unstable-mscs,api,canonical-json,client-api,events,html-matrix,identity-service-api,js,markdown,rand,signatures,unstable-unspecified -- -D warnings
" "
) )
.env("CLIPPY_CONF_DIR", ".wasm")
.run() .run()
.map_err(Into::into) .map_err(Into::into)
} }

View File

@ -15,7 +15,7 @@ use serde::Deserialize;
use serde_json::from_str as from_json_str; use serde_json::from_str as from_json_str;
// Keep in sync with version in `rust-toolchain.toml` and `.github/workflows/ci.yml` // Keep in sync with version in `rust-toolchain.toml` and `.github/workflows/ci.yml`
const NIGHTLY: &str = "nightly-2024-05-09"; const NIGHTLY: &str = "nightly-2024-07-29";
mod cargo; mod cargo;
mod ci; mod ci;