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_REGISTRIES_CRATES_IO_PROTOCOL: sparse
# 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:
push:
@ -181,6 +181,11 @@ jobs:
cmd: clippy-all
components: clippy
- name: Clippy WASM
cmd: clippy-wasm
targets: wasm32-unknown-unknown
components: clippy
steps:
- name: Checkout repo
uses: actions/checkout@v4
@ -190,6 +195,7 @@ jobs:
with:
toolchain: ${{ env.NIGHTLY }}
components: ${{ matrix.components }}
targets: ${{ matrix.targets }}
- 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"
assign = "1.1.1"
base64 = "0.22.0"
bytes = "1.0.1"
criterion = "0.5.0"
http = "1.1.0"
js_int = "0.2.2"
maplit = "1.0.2"
rand = "0.8.5"
ruma-appservice-api = { version = "0.10.0", path = "crates/ruma-appservice-api" }
ruma-common = { version = "0.13.0", path = "crates/ruma-common" }
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`
from `Box<ClientSecret>` to `OwnedClientSecret`
- 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:
@ -31,6 +34,8 @@ Bug fixes:
- `user_id` of `SlidingSyncRoomHero` is now mandatory
- Make authentication with access token optional for the `change_password` and
`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

View File

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

View File

@ -12,6 +12,7 @@ pub mod v1 {
use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use ruma_common::{
api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName,
};
@ -43,8 +44,8 @@ pub mod v1 {
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout"
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
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
/// 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)]
pub content_disposition: Option<String>,
pub content_disposition: Option<ContentDisposition>,
}
impl Request {
/// Creates a new `Request` with the given media ID and server name.
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.

View File

@ -12,6 +12,7 @@ pub mod v1 {
use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE};
use ruma_common::{
api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName,
};
@ -47,8 +48,8 @@ pub mod v1 {
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout"
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
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
/// 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)]
pub content_disposition: Option<String>,
pub content_disposition: Option<ContentDisposition>,
}
impl Request {
@ -81,7 +78,7 @@ pub mod v1 {
media_id,
server_name,
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 ruma_common::{
api::{request, response, Metadata},
media::Method,
metadata, IdParseError, MxcUri, OwnedServerName,
};
use crate::media::get_content_thumbnail::v3::Method;
const METADATA: Metadata = metadata! {
method: GET,
rate_limited: true,
@ -63,8 +62,8 @@ pub mod v1 {
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout"
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
pub timeout_ms: Duration,
@ -105,7 +104,7 @@ pub mod v1 {
method: None,
width,
height,
timeout_ms: crate::media::default_download_timeout(),
timeout_ms: ruma_common::media::default_download_timeout(),
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 {
//! `msc4140` ([MSC])
@ -16,17 +16,18 @@ pub mod unstable {
use ruma_events::{AnyMessageLikeEventContent, MessageLikeEventContent, MessageLikeEventType};
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! {
method: PUT,
rate_limited: false,
authentication: AccessToken,
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.
#[request(error = crate::Error)]
pub struct Request {
@ -50,12 +51,9 @@ pub mod unstable {
#[ruma_api(path)]
pub txn_id: OwnedTransactionId,
/// Additional parameters to describe sending a future.
///
/// Only three combinations for `future_timeout` and `future_group_id` are possible.
/// The enum [`FutureParameters`] enforces this.
/// The timeout duration for this delayed event.
#[ruma_api(query_all)]
pub future_parameters: FutureParameters,
pub delay_parameters: DelayParameters,
/// The event content to send.
#[ruma_api(body)]
@ -63,26 +61,15 @@ pub mod unstable {
}
/// 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)]
pub struct Response {
/// A token to send/insert the future into the DAG.
pub send_token: String,
/// A token to cancel this future. It will never be send if this is called.
pub cancel_token: String,
/// The `future_group_id` generated for this future. Used to connect multiple futures
/// only one of the connected futures will be sent and inserted into the DAG.
pub future_group_id: String,
/// A token used to refresh the timer of the future. This allows
/// to implement heartbeat like capabilities. An event is only sent once
/// a refresh in the timeout interval is missed.
///
/// If the future does not have a timeout this will be `None`.
pub refresh_token: Option<String>,
/// 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, transaction id future_parameters and
/// Creates a new `Request` with the given room id, transaction id, `delay_parameters` and
/// event content.
///
/// # Errors
@ -92,7 +79,7 @@ pub mod unstable {
pub fn new<T>(
room_id: OwnedRoomId,
txn_id: OwnedTransactionId,
future_parameters: FutureParameters,
delay_parameters: DelayParameters,
content: &T,
) -> serde_json::Result<Self>
where
@ -102,34 +89,29 @@ pub mod unstable {
room_id,
txn_id,
event_type: content.event_type(),
future_parameters,
delay_parameters,
body: Raw::from_json(to_raw_json_value(content)?),
})
}
/// Creates a new `Request` with the given room id, transaction id, event type,
/// future parameters and raw event content.
/// `delay_parameters` and raw event content.
pub fn new_raw(
room_id: OwnedRoomId,
txn_id: OwnedTransactionId,
event_type: MessageLikeEventType,
future_parameters: FutureParameters,
delay_parameters: DelayParameters,
body: Raw<AnyMessageLikeEventContent>,
) -> Self {
Self { room_id, event_type, txn_id, future_parameters, body }
Self { room_id, event_type, txn_id, delay_parameters, body }
}
}
impl Response {
/// Creates a new `Response` with the tokens required to control the future using the
/// [`crate::future::update_future::unstable::Request`] request.
pub fn new(
send_token: String,
cancel_token: String,
future_group_id: String,
refresh_token: Option<String>,
) -> Self {
Self { send_token, cancel_token, future_group_id, refresh_token }
/// 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 }
}
}
@ -144,19 +126,16 @@ pub mod unstable {
use web_time::Duration;
use super::Request;
use crate::future::send_future_message_event::unstable::FutureParameters;
use crate::delayed_events::delayed_message_event::unstable::DelayParameters;
#[test]
fn serialize_message_future_request() {
fn serialize_delayed_message_request() {
let room_id = owned_room_id!("!roomid:example.org");
let req = Request::new(
room_id,
"1234".into(),
FutureParameters::Timeout {
timeout: Duration::from_millis(103),
group_id: Some("testId".to_owned()),
},
DelayParameters::Timeout { timeout: Duration::from_millis(103) },
&RoomMessageEventContent::text_plain("test"),
)
.unwrap();
@ -169,7 +148,7 @@ pub mod unstable {
.unwrap();
let (parts, body) = request.into_parts();
assert_eq!(
"https://homeserver.tld/_matrix/client/unstable/org.matrix.msc4140/rooms/!roomid:example.org/send_future/m.room.message/1234?future_timeout=103&future_group_id=testId",
"https://homeserver.tld/_matrix/client/v3/rooms/!roomid:example.org/send/m.room.message/1234?org.matrix.msc4140.delay=103",
parts.uri.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 ruma_common::api::error::{HeaderDeserializationError, HeaderSerializationError};
pub use ruma_common::http_headers::{
ContentDisposition, ContentDispositionParseError, ContentDispositionType, TokenString,
TokenStringParseError,
};
use web_time::{Duration, SystemTime, UNIX_EPOCH};
/// The [`Cross-Origin-Resource-Policy`] HTTP response header.

View File

@ -18,13 +18,13 @@ pub mod config;
pub mod context;
#[cfg(feature = "unstable-msc3814")]
pub mod dehydrated_device;
#[cfg(feature = "unstable-msc4140")]
pub mod delayed_events;
pub mod device;
pub mod directory;
pub mod discovery;
pub mod error;
pub mod filter;
#[cfg(feature = "unstable-msc4140")]
pub mod future;
pub mod http_headers;
pub mod keys;
pub mod knock;

View File

@ -1,7 +1,5 @@
//! Endpoints for the media repository.
use std::time::Duration;
pub mod create_content;
pub mod create_content_async;
pub mod create_mxc_uri;
@ -10,14 +8,3 @@ pub mod get_content_as_filename;
pub mod get_content_thumbnail;
pub mod get_media_config;
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 ruma_common::{
api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName,
};
@ -60,8 +61,8 @@ pub mod v3 {
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout"
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
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
/// 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)]
pub content_disposition: Option<String>,
pub content_disposition: Option<ContentDisposition>,
/// The value of the `Cross-Origin-Resource-Policy` HTTP header.
///
@ -119,7 +116,7 @@ pub mod v3 {
media_id,
server_name,
allow_remote: true,
timeout_ms: crate::media::default_download_timeout(),
timeout_ms: ruma_common::media::default_download_timeout(),
allow_redirect: false,
}
}

View File

@ -12,6 +12,7 @@ pub mod v3 {
use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE};
use ruma_common::{
api::{request, response, Metadata},
http_headers::ContentDisposition,
metadata, IdParseError, MxcUri, OwnedServerName,
};
@ -64,8 +65,8 @@ pub mod v3 {
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout"
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
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
/// 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)]
pub content_disposition: Option<String>,
pub content_disposition: Option<ContentDisposition>,
/// The value of the `Cross-Origin-Resource-Policy` HTTP header.
///
@ -124,7 +121,7 @@ pub mod v3 {
server_name,
filename,
allow_remote: true,
timeout_ms: crate::media::default_download_timeout(),
timeout_ms: ruma_common::media::default_download_timeout(),
allow_redirect: false,
}
}

View File

@ -11,14 +11,13 @@ pub mod v3 {
use http::header::{CACHE_CONTROL, CONTENT_DISPOSITION, CONTENT_TYPE};
use js_int::UInt;
pub use ruma_common::media::Method;
use ruma_common::{
api::{request, response, Metadata},
metadata,
serde::StringEnum,
IdParseError, MxcUri, OwnedServerName,
metadata, IdParseError, MxcUri, OwnedServerName,
};
use crate::{http_headers::CROSS_ORIGIN_RESOURCE_POLICY, PrivOwnedStr};
use crate::http_headers::CROSS_ORIGIN_RESOURCE_POLICY;
const METADATA: Metadata = metadata! {
method: GET,
@ -80,8 +79,8 @@ pub mod v3 {
#[ruma_api(query)]
#[serde(
with = "ruma_common::serde::duration::ms",
default = "crate::media::default_download_timeout",
skip_serializing_if = "crate::media::is_default_download_timeout"
default = "ruma_common::media::default_download_timeout",
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
)]
pub timeout_ms: Duration,
@ -157,7 +156,7 @@ pub mod v3 {
width,
height,
allow_remote: true,
timeout_ms: crate::media::default_download_timeout(),
timeout_ms: ruma_common::media::default_download_timeout(),
allow_redirect: false,
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
use ruma_common::{
api::{request, response, Metadata},
api::{response, Metadata},
metadata,
};
@ -23,15 +23,10 @@ pub mod v3 {
};
/// Request type for the `logout` endpoint.
#[request(error = crate::Error)]
#[derive(Default)]
#[derive(Debug, Clone, Default)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Request {}
/// Response type for the `logout` endpoint.
#[response(error = crate::Error)]
#[derive(Default)]
pub struct Response {}
impl Request {
/// Creates an empty `Request`.
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 {
/// Creates an empty `Response`.
pub fn new() -> Self {

View File

@ -8,7 +8,7 @@ pub mod v3 {
//! [spec]: https://spec.matrix.org/latest/client-server-api/#post_matrixclientv3logoutall
use ruma_common::{
api::{request, response, Metadata},
api::{response, Metadata},
metadata,
};
@ -23,15 +23,10 @@ pub mod v3 {
};
/// Request type for the `logout_all` endpoint.
#[request(error = crate::Error)]
#[derive(Default)]
#[derive(Debug, Clone, Default)]
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
pub struct Request {}
/// Response type for the `logout_all` endpoint.
#[response(error = crate::Error)]
#[derive(Default)]
pub struct Response {}
impl Request {
/// Creates an empty `Request`.
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 {
/// Creates an empty `Response`.
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 }
assign = { workspace = true }
async-stream = "0.3.0"
bytes = "1.0.1"
bytes = { workspace = true }
futures-core = "0.3.8"
http = { workspace = 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
query parameters. Note that the (de)serialization of the type used must work
with `serde_html_form`.
- The `header` attribute for the `request` and `response` macros accepts any
type that implements `ToString` and `FromStr`.
Improvements:
@ -25,6 +27,7 @@ Improvements:
parameter is deprecated, according to MSC4126 / Matrix 1.11.
- Constructing a Matrix URI for an event with a room alias is deprecated,
according to MSC4132 / Matrix 1.11
- Implement `Eq` and `PartialEq` for `Metadata`
# 0.13.0

View File

@ -59,7 +59,7 @@ compat-optional = []
[dependencies]
as_variant = { workspace = true }
base64 = { workspace = true }
bytes = "1.0.1"
bytes = { workspace = true }
form_urlencoded = "1.0.0"
getrandom = { version = "0.2.6", optional = true }
http = { workspace = true, optional = true }
@ -71,11 +71,8 @@ konst = { version = "0.3.5", default-features = false, features = [
"parsing",
], optional = true }
percent-encoding = "2.1.0"
rand = { version = "0.8.3", optional = true }
regex = { version = "1.5.6", default-features = false, features = [
"std",
"perf",
] }
rand = { workspace = true, optional = true }
regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] }
ruma-identifiers-validation = { workspace = true }
ruma-macros = { 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:
///
/// * `#[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`.
/// The attribute value shown above as `HEADER_NAME` must be a `const` expression of the type
/// `http::header::HeaderName`, like one of the constants from `http::header`, e.g.
/// `CONTENT_TYPE`.
/// headers on the request. The value must implement `ToString` and `FromStr`. Generally this
/// is a `String`. The attribute value shown above as `HEADER_NAME` must be a `const`
/// expression of the type `http::header::HeaderName`, like one of the constants from
/// `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
/// 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.
@ -230,9 +232,11 @@ pub use ruma_macros::request;
/// To declare which part of the response a field belongs to:
///
/// * `#[ruma_api(header = HEADER_NAME)]`: Fields with this attribute will be treated as HTTP
/// headers on the response. The value must implement `Display`. Generally this is a
/// `String`. The attribute value shown above as `HEADER_NAME` must be a header name constant
/// from `http::header`, e.g. `CONTENT_TYPE`.
/// headers on the response. The value must implement `ToString` and `FromStr`. Generally
/// this is a `String`. The attribute value shown above as `HEADER_NAME` must be a header
/// 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]`
/// attributes to customize (de)serialization.
/// * `#[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.
#[error(transparent)]
Header(#[from] HeaderDeserializationError),
/// Deserialization of `multipart/mixed` response failed.
#[error(transparent)]
MultipartMixed(#[from] MultipartMixedDeserializationError),
}
impl From<std::convert::Infallible> for DeserializationError {
@ -277,6 +281,10 @@ pub enum HeaderDeserializationError {
#[error("missing header `{0}`")]
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.
#[error(
"The {header} header was received with an unexpected value, \
@ -290,6 +298,42 @@ pub enum HeaderDeserializationError {
/// The value we instead received and rejected.
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.

View File

@ -19,7 +19,7 @@ use super::{
use crate::{percent_encode::PATH_PERCENT_ENCODE_SET, serde::slice_to_buf, RoomVersionId};
/// Metadata about an API endpoint.
#[derive(Clone, Debug)]
#[derive(Clone, Debug, PartialEq, Eq)]
#[allow(clippy::exhaustive_structs)]
pub struct Metadata {
/// The HTTP method used by this endpoint.
@ -141,7 +141,7 @@ impl Metadata {
/// versions stable and unstable.
///
/// 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)]
pub struct VersionHistory {
/// 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 directory;
pub mod encryption;
pub mod http_headers;
mod identifiers;
pub mod media;
mod percent_encode;
pub mod power_levels;
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]
fn serialize_string() {
assert_eq!(to_json_value(&Action::Notify).unwrap(), json!("notify"));
assert_eq!(to_json_value(Action::Notify).unwrap(), json!("notify"));
}
#[test]
fn serialize_tweak_sound() {
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" })
);
}
@ -202,12 +202,12 @@ mod tests {
#[test]
fn serialize_tweak_highlight() {
assert_eq!(
to_json_value(&Action::SetTweak(Tweak::Highlight(true))).unwrap(),
to_json_value(Action::SetTweak(Tweak::Highlight(true))).unwrap(),
json!({ "set_tweak": "highlight" })
);
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 })
);
}

View File

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

View File

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

View File

@ -7,6 +7,7 @@ mod header_override;
mod manual_endpoint_impl;
mod no_fields;
mod optional_headers;
mod required_headers;
mod ruma_api;
mod ruma_api_macros;
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::{
api::{request, response, Metadata},
api::{
request, response, IncomingRequest, IncomingResponse, MatrixVersion, Metadata,
OutgoingRequest, OutgoingResponse, SendAccessToken,
},
http_headers::{ContentDisposition, ContentDispositionType},
metadata,
};
@ -13,16 +18,128 @@ const METADATA: Metadata = metadata! {
}
};
/// Request type for the `no_fields` endpoint.
/// Request type for the `optional_headers` endpoint.
#[request]
pub struct Request {
#[ruma_api(header = LOCATION)]
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]
pub struct Response {
#[ruma_api(header = LOCATION)]
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 }
tracing = { workspace = true, features = ["attributes"] }
url = { workspace = true }
web-time = { workspace = true }
wildmatch = "2.0.0"
# 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
use std::time::{Duration, SystemTime};
use ruma_common::{MilliSecondsSinceUnixEpoch, OwnedUserId};
use ruma_macros::EventContent;
use serde::{Deserialize, Serialize};
use web_time::{Duration, SystemTime};
use crate::location::AssetContent;

View File

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

View File

@ -36,13 +36,13 @@ fn serialize_redacted_message_event_content() {
#[test]
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]
fn redacted_aliases_event_serialize_with_content() {
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);
}

View File

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

View File

@ -19,8 +19,8 @@ all-features = true
# them to an empty string in deserialization.
compat-empty-string-null = []
client = []
server = []
client = ["dep:httparse", "dep:memchr"]
server = ["dep:bytes", "dep:rand"]
unstable-exhaustive-types = []
unstable-msc2448 = []
unstable-msc3618 = []
@ -30,7 +30,13 @@ unstable-msc4125 = []
unstable-unspecified = []
[dependencies]
bytes = { workspace = true, optional = true }
http = { workspace = true }
httparse = { version = "1.9.0", optional = true }
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-events = { 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;
pub mod authenticated_media;
pub mod authorization;
pub mod backfill;
pub mod device;

View File

@ -80,18 +80,38 @@ impl Request {
syn::Type::Path(syn::TypePath {
path: syn::Path { segments, .. }, ..
}) 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! {

View File

@ -66,7 +66,7 @@ impl Request {
if let Some(header_val) = self.#field_name.as_ref() {
req_headers.insert(
#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! {
req_headers.insert(
#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 {
path: syn::Path { segments, .. }, ..
}) 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! {
#( #cfg_attrs )*
#field_name: {
headers.remove(#header_name)
.map(|h| h.to_str().map(|s| s.to_owned()))
.transpose()?
.and_then(|h| { h.to_str().ok()?.parse::<#field_type>().ok() })
}
}
}
_ => quote! {
#( #cfg_attrs )*
#field_name: {
headers.remove(#header_name)
.expect("response missing expected header")
.to_str()?
.to_owned()
_ => {
let field_type = &field.ty;
quote! {
#( #cfg_attrs )*
#field_name: {
headers.remove(#header_name)
.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 }
}

View File

@ -22,7 +22,7 @@ impl Response {
if let Some(header) = self.#field_name {
headers.insert(
#header_name,
header.parse()?,
header.to_string().parse()?,
);
}
}
@ -30,7 +30,7 @@ impl Response {
_ => quote! {
headers.insert(
#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
// true.
"highlight" => {
let highlight = if let Ok(highlight) = access.next_value() {
highlight
} else {
true
};
let highlight = access.next_value().unwrap_or(true);
tweaks.push(Tweak::Highlight(highlight));
}

View File

@ -1,11 +1,12 @@
//! Common types for implementing federation authorization.
use std::{borrow::Cow, fmt, str::FromStr};
use std::{fmt, str::FromStr};
use headers::authorization::Credentials;
use http::HeaderValue;
use http_auth::ChallengeParser;
use ruma_common::{
http_headers::quote_ascii_string_if_required,
serde::{Base64, Base64DecodeError},
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 {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let Self { origin, destination, key, sig } = self;
let origin = escape_field_value(origin.as_str());
let key = escape_field_value(key.as_str());
let origin = quote_ascii_string_if_required(origin.as_str());
let key = quote_ascii_string_if_required(key.as_str());
let sig = sig.encode();
let sig = escape_field_value(&sig);
let sig = quote_ascii_string_if_required(&sig);
write!(f, r#"{} "#, Self::SCHEME)?;
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},"#)?;
}

View File

@ -23,7 +23,7 @@ unstable-exhaustive-types = []
base64 = { workspace = true }
ed25519-dalek = { version = "2.0.0", features = ["pkcs8", "rand_core"] }
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"] }
serde_json = { workspace = true }
sha2 = "0.10.6"

View File

@ -40,7 +40,7 @@ static REFERENCE_HASH_FIELDS_TO_REMOVE: &[&str] = &["signatures", "unsigned"];
/// # Parameters
///
/// * 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.
/// * 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
///
/// * 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.
/// "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.
/// 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.
/// "ed25519:1") then map to their respective public keys.
/// * object: The JSON object that was signed.
///
/// # Errors
@ -355,7 +355,7 @@ pub fn reference_hash(
/// # Parameters
///
/// * 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.
/// * object: A JSON object to be hashed and signed according to the Matrix specification.
///
@ -486,9 +486,9 @@ where
/// # Parameters
///
/// * 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.
/// "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.
/// 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.
/// "ed25519:1") then map to their respective public keys.
/// * object: The JSON object of the event that was signed.
/// * version: Room version of the given event
///

View File

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

View File

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

View File

@ -1,4 +1,4 @@
[toolchain]
# 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"]

View File

@ -60,7 +60,7 @@ pub enum CiCmd {
NightlyAll,
/// Lint default features with clippy (nightly)
ClippyDefault,
/// Lint ruma-common with clippy on a wasm target (nightly)
/// Lint client features with clippy on a wasm target (nightly)
ClippyWasm,
/// Lint almost all features with clippy (nightly)
ClippyAll,
@ -289,17 +289,15 @@ impl CiTask {
.map_err(Into::into)
}
/// Lint ruma-common 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.
/// Lint ruma with clippy with the nightly version and wasm target.
fn clippy_wasm(&self) -> Result<()> {
cmd!(
"
rustup run {NIGHTLY} cargo clippy --target wasm32-unknown-unknown
-p ruma-common --features api,js,rand
rustup run {NIGHTLY} cargo clippy --target wasm32-unknown-unknown -p ruma --features
__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()
.map_err(Into::into)
}

View File

@ -15,7 +15,7 @@ use serde::Deserialize;
use serde_json::from_str as from_json_str;
// 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 ci;