Merge remote-tracking branch 'upstream/main' into conduwuit-changes
This commit is contained in:
commit
69b2bc4b8c
8
.github/workflows/ci.yml
vendored
8
.github/workflows/ci.yml
vendored
@ -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
25
.wasm/.clippy.toml
Normal 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 = "{" },
|
||||
]
|
@ -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" }
|
||||
|
@ -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
|
||||
|
||||
|
@ -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"] }
|
||||
|
@ -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.
|
||||
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
37
crates/ruma-client-api/src/delayed_events.rs
Normal file
37
crates/ruma-client-api/src/delayed_events.rs
Normal 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,
|
||||
},
|
||||
}
|
@ -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());
|
162
crates/ruma-client-api/src/delayed_events/delayed_state_event.rs
Normal file
162
crates/ruma-client-api/src/delayed_events/delayed_state_event.rs
Normal 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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
},
|
||||
}
|
@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -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 {}
|
||||
}
|
||||
}
|
||||
}
|
@ -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.
|
||||
|
@ -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;
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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 {
|
||||
|
@ -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 }
|
||||
|
@ -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
|
||||
|
||||
|
@ -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 }
|
||||
|
@ -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
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
104
crates/ruma-common/src/http_headers.rs
Normal file
104
crates/ruma-common/src/http_headers.rs
Normal 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()
|
||||
}
|
736
crates/ruma-common/src/http_headers/content_disposition.rs
Normal file
736
crates/ruma-common/src/http_headers/content_disposition.rs
Normal 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"#);
|
||||
}
|
||||
}
|
76
crates/ruma-common/src/http_headers/rfc8187.rs
Normal file
76
crates/ruma-common/src/http_headers/rfc8187.rs
Normal 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,
|
||||
}
|
@ -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;
|
||||
|
34
crates/ruma-common/src/media.rs
Normal file
34
crates/ruma-common/src/media.rs
Normal 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
|
||||
}
|
@ -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 })
|
||||
);
|
||||
}
|
||||
|
@ -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![];
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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;
|
||||
|
@ -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);
|
||||
}
|
||||
|
130
crates/ruma-common/tests/api/required_headers.rs
Normal file
130
crates/ruma-common/tests/api/required_headers.rs
Normal 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(_)
|
||||
))
|
||||
);
|
||||
}
|
@ -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
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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 =
|
||||
|
@ -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);
|
||||
}
|
||||
|
||||
|
@ -1,5 +1,9 @@
|
||||
# [unreleased]
|
||||
|
||||
Improvements:
|
||||
|
||||
- Add support for authenticated media endpoints, according to MSC3916 / Matrix 1.11
|
||||
|
||||
# 0.9.0
|
||||
|
||||
Breaking changes:
|
||||
|
@ -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 }
|
||||
|
511
crates/ruma-federation-api/src/authenticated_media.rs
Normal file
511
crates/ruma-federation-api/src/authenticated_media.rs
Normal 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(), "ȵ⌾Ⱦԩ💈Ňɠ");
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ use std::fmt;
|
||||
|
||||
mod serde;
|
||||
|
||||
pub mod authenticated_media;
|
||||
pub mod authorization;
|
||||
pub mod backfill;
|
||||
pub mod device;
|
||||
|
@ -80,10 +80,29 @@ 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 }
|
||||
)
|
||||
}
|
||||
_ => (
|
||||
quote! { str_value.to_owned() },
|
||||
_ => {
|
||||
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(
|
||||
@ -91,7 +110,8 @@ impl Request {
|
||||
).into(),
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let decl = quote! {
|
||||
|
@ -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())?,
|
||||
);
|
||||
},
|
||||
}
|
||||
|
@ -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! {
|
||||
_ => {
|
||||
let field_type = &field.ty;
|
||||
quote! {
|
||||
#( #cfg_attrs )*
|
||||
#field_name: {
|
||||
headers.remove(#header_name)
|
||||
.expect("response missing expected header")
|
||||
.ok_or_else(|| #ruma_common::api::error::HeaderDeserializationError::MissingHeader(
|
||||
#header_name.to_string()
|
||||
))?
|
||||
.to_str()?
|
||||
.to_owned()
|
||||
.parse::<#field_type>()
|
||||
.map_err(|e| #ruma_common::api::error::HeaderDeserializationError::InvalidHeader(e.into()))?
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
quote! { #optional_header }
|
||||
}
|
||||
|
@ -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()?,
|
||||
);
|
||||
},
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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},"#)?;
|
||||
}
|
||||
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
||||
|
@ -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]
|
||||
|
@ -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"]
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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;
|
||||
|
Loading…
x
Reference in New Issue
Block a user