From 7f562fe67efdc960fbc86a59207784efc1bbee1f Mon Sep 17 00:00:00 2001 From: torrybr <16907963+torrybr@users.noreply.github.com> Date: Mon, 8 Jul 2024 04:18:03 -0400 Subject: [PATCH 01/15] events: use web-time instead of std::time for BeaconInfoEventContent --- crates/ruma-events/Cargo.toml | 1 + crates/ruma-events/src/beacon_info.rs | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ruma-events/Cargo.toml b/crates/ruma-events/Cargo.toml index 18b6ae01..210e251b 100644 --- a/crates/ruma-events/Cargo.toml +++ b/crates/ruma-events/Cargo.toml @@ -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 diff --git a/crates/ruma-events/src/beacon_info.rs b/crates/ruma-events/src/beacon_info.rs index 5c7a2947..87e8204f 100644 --- a/crates/ruma-events/src/beacon_info.rs +++ b/crates/ruma-events/src/beacon_info.rs @@ -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; From 5ebe200bb686f27f7ded786b046e55ff47cd8dc0 Mon Sep 17 00:00:00 2001 From: torrybr <16907963+torrybr@users.noreply.github.com> Date: Thu, 11 Jul 2024 03:47:03 -0400 Subject: [PATCH 02/15] ci: Improve and run clippy-wasm command Lint more crates with it and enable more features. Also only allow web-time types. --- .github/workflows/ci.yml | 6 ++++++ .wasm/.clippy.toml | 25 +++++++++++++++++++++++++ crates/ruma/Cargo.toml | 15 +++++++++------ xtask/src/ci.rs | 12 +++++------- 4 files changed, 45 insertions(+), 13 deletions(-) create mode 100644 .wasm/.clippy.toml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 652199ce..3d489c40 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.wasm/.clippy.toml b/.wasm/.clippy.toml new file mode 100644 index 00000000..f31de1d1 --- /dev/null +++ b/.wasm/.clippy.toml @@ -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 = "{" }, +] diff --git a/crates/ruma/Cargo.toml b/crates/ruma/Cargo.toml index b3fa0af7..bfb40d56 100644 --- a/crates/ruma/Cargo.toml +++ b/crates/ruma/Cargo.toml @@ -235,11 +235,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", @@ -281,7 +278,13 @@ __ci = [ "unstable-msc4108", "unstable-msc4121", "unstable-msc4125", - "unstable-msc4140" + "unstable-msc4140", +] +__ci = [ + "full", + "compat-upload-signatures", + "__unstable-mscs", + "unstable-unspecified", ] [dependencies] diff --git a/xtask/src/ci.rs b/xtask/src/ci.rs index 0ff57663..058c563d 100644 --- a/xtask/src/ci.rs +++ b/xtask/src/ci.rs @@ -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) } From 50a46cc5f658fd1cef5bdae6f08db292c3135366 Mon Sep 17 00:00:00 2001 From: Matthias Ahouansou Date: Thu, 11 Jul 2024 19:47:48 +0000 Subject: [PATCH 03/15] api: implement (Partial)Eq for Metadata --- crates/ruma-common/CHANGELOG.md | 1 + crates/ruma-common/src/api/metadata.rs | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 0fb96ca7..6f6909e5 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -25,6 +25,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 diff --git a/crates/ruma-common/src/api/metadata.rs b/crates/ruma-common/src/api/metadata.rs index 1a877aca..398b8b46 100644 --- a/crates/ruma-common/src/api/metadata.rs +++ b/crates/ruma-common/src/api/metadata.rs @@ -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. From cc56e5277b8f17c7cd987723b25873c4b1e2d3b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 27 Jun 2024 15:50:40 +0200 Subject: [PATCH 04/15] serde: Re-export AsStrAsRefStr derive macro It seems it was forgotten in the list of reexports. --- crates/ruma-common/src/serde.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/ruma-common/src/serde.rs b/crates/ruma-common/src/serde.rs index 705f2355..99a27db6 100644 --- a/crates/ruma-common/src/serde.rs +++ b/crates/ruma-common/src/serde.rs @@ -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, }; From f73ba5556c08518109d3647ce89c11ce05847b7e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 27 Jun 2024 18:44:20 +0200 Subject: [PATCH 05/15] api: Allow types implementing ToString and FromStr for header values Contrary to what the previous docs said, types implementing Display did not work, only string types worked. --- crates/ruma-common/CHANGELOG.md | 2 + crates/ruma-common/src/api.rs | 18 ++++---- crates/ruma-common/src/api/error.rs | 4 ++ .../ruma-macros/src/api/request/incoming.rs | 42 ++++++++++++++----- .../ruma-macros/src/api/request/outgoing.rs | 4 +- .../ruma-macros/src/api/response/incoming.rs | 33 ++++++++++----- .../ruma-macros/src/api/response/outgoing.rs | 4 +- 7 files changed, 75 insertions(+), 32 deletions(-) diff --git a/crates/ruma-common/CHANGELOG.md b/crates/ruma-common/CHANGELOG.md index 6f6909e5..4f979bda 100644 --- a/crates/ruma-common/CHANGELOG.md +++ b/crates/ruma-common/CHANGELOG.md @@ -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: diff --git a/crates/ruma-common/src/api.rs b/crates/ruma-common/src/api.rs index ae8d61a0..4b288bed 100644 --- a/crates/ruma-common/src/api.rs +++ b/crates/ruma-common/src/api.rs @@ -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 diff --git a/crates/ruma-common/src/api/error.rs b/crates/ruma-common/src/api/error.rs index f60052fb..aa066678 100644 --- a/crates/ruma-common/src/api/error.rs +++ b/crates/ruma-common/src/api/error.rs @@ -277,6 +277,10 @@ pub enum HeaderDeserializationError { #[error("missing header `{0}`")] MissingHeader(String), + /// The given header failed to parse. + #[error("invalid header: {0}")] + InvalidHeader(Box), + /// A header was received with a unexpected value. #[error( "The {header} header was received with an unexpected value, \ diff --git a/crates/ruma-macros/src/api/request/incoming.rs b/crates/ruma-macros/src/api/request/incoming.rs index 5f81bdce..44b7db90 100644 --- a/crates/ruma-macros/src/api/request/incoming.rs +++ b/crates/ruma-macros/src/api/request/incoming.rs @@ -80,18 +80,38 @@ impl Request { syn::Type::Path(syn::TypePath { path: syn::Path { segments, .. }, .. }) if segments.last().unwrap().ident == "Option" => { - (quote! { Some(str_value.to_owned()) }, quote! { None }) + let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments { + args: option_args, .. + }) = &segments.last().unwrap().arguments else { + panic!("Option should use angle brackets"); + }; + let syn::GenericArgument::Type(field_type) = option_args.first().unwrap() else { + panic!("Option brackets should contain type"); + }; + ( + quote! { + str_value.parse::<#field_type>().ok() + }, + quote! { None } + ) + } + _ => { + let field_type = &field.ty; + ( + quote! { + str_value + .parse::<#field_type>() + .map_err(|e| #ruma_common::api::error::HeaderDeserializationError::InvalidHeader(e.into()))? + }, + quote! { + return Err( + #ruma_common::api::error::HeaderDeserializationError::MissingHeader( + #header_name_string.into() + ).into(), + ) + }, + ) } - _ => ( - quote! { str_value.to_owned() }, - quote! { - return Err( - #ruma_common::api::error::HeaderDeserializationError::MissingHeader( - #header_name_string.into() - ).into(), - ) - }, - ), }; let decl = quote! { diff --git a/crates/ruma-macros/src/api/request/outgoing.rs b/crates/ruma-macros/src/api/request/outgoing.rs index 6555d58a..6bac6d2c 100644 --- a/crates/ruma-macros/src/api/request/outgoing.rs +++ b/crates/ruma-macros/src/api/request/outgoing.rs @@ -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())?, ); }, } diff --git a/crates/ruma-macros/src/api/response/incoming.rs b/crates/ruma-macros/src/api/response/incoming.rs index a37bfa5d..8be16793 100644 --- a/crates/ruma-macros/src/api/response/incoming.rs +++ b/crates/ruma-macros/src/api/response/incoming.rs @@ -56,24 +56,37 @@ impl Response { Type::Path(syn::TypePath { path: syn::Path { segments, .. }, .. }) if segments.last().unwrap().ident == "Option" => { + let syn::PathArguments::AngleBracketed(syn::AngleBracketedGenericArguments { + args: option_args, .. + }) = &segments.last().unwrap().arguments else { + panic!("Option should use angle brackets"); + }; + let syn::GenericArgument::Type(field_type) = option_args.first().unwrap() else { + panic!("Option brackets should contain type"); + }; quote! { #( #cfg_attrs )* #field_name: { headers.remove(#header_name) - .map(|h| h.to_str().map(|s| s.to_owned())) - .transpose()? + .and_then(|h| { h.to_str().ok()?.parse::<#field_type>().ok() }) } } } - _ => quote! { - #( #cfg_attrs )* - #field_name: { - headers.remove(#header_name) - .expect("response missing expected header") - .to_str()? - .to_owned() + _ => { + let field_type = &field.ty; + quote! { + #( #cfg_attrs )* + #field_name: { + headers.remove(#header_name) + .ok_or_else(|| #ruma_common::api::error::HeaderDeserializationError::MissingHeader( + #header_name.to_string() + ))? + .to_str()? + .parse::<#field_type>() + .map_err(|e| #ruma_common::api::error::HeaderDeserializationError::InvalidHeader(e.into()))? + } } - }, + } }; quote! { #optional_header } } diff --git a/crates/ruma-macros/src/api/response/outgoing.rs b/crates/ruma-macros/src/api/response/outgoing.rs index ea24aa17..114d95d8 100644 --- a/crates/ruma-macros/src/api/response/outgoing.rs +++ b/crates/ruma-macros/src/api/response/outgoing.rs @@ -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()?, ); }, } From 463f89b0eb8e606f12587247fb99f9b6efed9fad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Thu, 27 Jun 2024 18:51:01 +0200 Subject: [PATCH 06/15] client-api: Add a type for the Content-Disposition HTTP header --- crates/ruma-client-api/CHANGELOG.md | 3 + .../src/authenticated_media/get_content.rs | 7 +- .../get_content_as_filename.rs | 7 +- crates/ruma-client-api/src/http_headers.rs | 4 + .../ruma-client-api/src/media/get_content.rs | 7 +- .../src/media/get_content_as_filename.rs | 7 +- crates/ruma-common/src/http_headers.rs | 104 +++ .../src/http_headers/content_disposition.rs | 736 ++++++++++++++++++ .../ruma-common/src/http_headers/rfc8187.rs | 76 ++ crates/ruma-common/src/lib.rs | 1 + crates/ruma-common/tests/api/mod.rs | 1 + .../ruma-common/tests/api/optional_headers.rs | 125 ++- .../ruma-common/tests/api/required_headers.rs | 130 ++++ crates/ruma-server-util/src/authorization.rs | 32 +- 14 files changed, 1190 insertions(+), 50 deletions(-) create mode 100644 crates/ruma-common/src/http_headers.rs create mode 100644 crates/ruma-common/src/http_headers/content_disposition.rs create mode 100644 crates/ruma-common/src/http_headers/rfc8187.rs create mode 100644 crates/ruma-common/tests/api/required_headers.rs diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index d92b1040..2606a168 100644 --- a/crates/ruma-client-api/CHANGELOG.md +++ b/crates/ruma-client-api/CHANGELOG.md @@ -7,6 +7,9 @@ Breaking changes: - Change type of `client_secret` field in `ThirdpartyIdCredentials` from `Box` 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: diff --git a/crates/ruma-client-api/src/authenticated_media/get_content.rs b/crates/ruma-client-api/src/authenticated_media/get_content.rs index e17b067f..efe12093 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content.rs @@ -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, }; @@ -62,12 +63,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, + pub content_disposition: Option, } impl Request { diff --git a/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs b/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs index 543dcf88..ff9f6f64 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs @@ -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, }; @@ -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, + pub content_disposition: Option, } impl Request { diff --git a/crates/ruma-client-api/src/http_headers.rs b/crates/ruma-client-api/src/http_headers.rs index ae963764..af24afcd 100644 --- a/crates/ruma-client-api/src/http_headers.rs +++ b/crates/ruma-client-api/src/http_headers.rs @@ -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. diff --git a/crates/ruma-client-api/src/media/get_content.rs b/crates/ruma-client-api/src/media/get_content.rs index 30dbfcf5..dc79a7b6 100644 --- a/crates/ruma-client-api/src/media/get_content.rs +++ b/crates/ruma-client-api/src/media/get_content.rs @@ -12,6 +12,7 @@ pub mod v3 { use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; use ruma_common::{ api::{request, response, Metadata}, + http_headers::ContentDisposition, metadata, IdParseError, MxcUri, OwnedServerName, }; @@ -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, + pub content_disposition: Option, /// The value of the `Cross-Origin-Resource-Policy` HTTP header. /// diff --git a/crates/ruma-client-api/src/media/get_content_as_filename.rs b/crates/ruma-client-api/src/media/get_content_as_filename.rs index 8bbbbf62..63f3ca11 100644 --- a/crates/ruma-client-api/src/media/get_content_as_filename.rs +++ b/crates/ruma-client-api/src/media/get_content_as_filename.rs @@ -12,6 +12,7 @@ pub mod v3 { use http::header::{CONTENT_DISPOSITION, CONTENT_TYPE}; use ruma_common::{ api::{request, response, Metadata}, + http_headers::ContentDisposition, metadata, IdParseError, MxcUri, OwnedServerName, }; @@ -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, + pub content_disposition: Option, /// The value of the `Cross-Origin-Resource-Policy` HTTP header. /// diff --git a/crates/ruma-common/src/http_headers.rs b/crates/ruma-common/src/http_headers.rs new file mode 100644 index 00000000..c7d9ece2 --- /dev/null +++ b/crates/ruma-common/src/http_headers.rs @@ -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() +} diff --git a/crates/ruma-common/src/http_headers/content_disposition.rs b/crates/ruma-common/src/http_headers/content_disposition.rs new file mode 100644 index 00000000..b90dacb5 --- /dev/null +++ b/crates/ruma-common/src/http_headers/content_disposition.rs @@ -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, +} + +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) -> 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 { + 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 { + 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 { + 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 { + 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::from_str(s) + } +} + +impl From 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 { + 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 { + s.as_bytes().try_into() + } +} + +impl PartialEq for ContentDispositionType { + fn eq(&self, other: &ContentDispositionType) -> bool { + self.as_str().eq_ignore_ascii_case(other.as_str()) + } +} + +impl Eq for ContentDispositionType {} + +impl PartialEq 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); + +impl TokenString { + /// Try parsing a `&str` into a `TokenString`. + pub fn parse(s: &str) -> Result { + Self::from_str(s) + } +} + +impl Deref for TokenString { + type Target = str; + + fn deref(&self) -> &Self::Target { + self.as_ref() + } +} + +impl AsRef 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 { + 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 { + 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"#); + } +} diff --git a/crates/ruma-common/src/http_headers/rfc8187.rs b/crates/ruma-common/src/http_headers/rfc8187.rs new file mode 100644 index 00000000..0311f075 --- /dev/null +++ b/crates/ruma-common/src/http_headers/rfc8187.rs @@ -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, 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, +} diff --git a/crates/ruma-common/src/lib.rs b/crates/ruma-common/src/lib.rs index 488088c4..8a188c50 100644 --- a/crates/ruma-common/src/lib.rs +++ b/crates/ruma-common/src/lib.rs @@ -24,6 +24,7 @@ pub mod authentication; pub mod canonical_json; pub mod directory; pub mod encryption; +pub mod http_headers; mod identifiers; mod percent_encode; pub mod power_levels; diff --git a/crates/ruma-common/tests/api/mod.rs b/crates/ruma-common/tests/api/mod.rs index 80e4fb08..15ceba74 100644 --- a/crates/ruma-common/tests/api/mod.rs +++ b/crates/ruma-common/tests/api/mod.rs @@ -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; diff --git a/crates/ruma-common/tests/api/optional_headers.rs b/crates/ruma-common/tests/api/optional_headers.rs index b81b5c57..78fc0746 100644 --- a/crates/ruma-common/tests/api/optional_headers.rs +++ b/crates/ruma-common/tests/api/optional_headers.rs @@ -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, + #[ruma_api(header = CONTENT_DISPOSITION)] + pub content_disposition: Option, } -/// 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, + #[ruma_api(header = CONTENT_DISPOSITION)] + pub content_disposition: Option, +} + +#[test] +fn request_serde_no_header() { + let req = Request { location: None, content_disposition: None }; + + let http_req = req + .clone() + .try_into_http_request::>( + "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::>( + "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::>().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::>().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); } diff --git a/crates/ruma-common/tests/api/required_headers.rs b/crates/ruma-common/tests/api/required_headers.rs new file mode 100644 index 00000000..1d8576d6 --- /dev/null +++ b/crates/ruma-common/tests/api/required_headers.rs @@ -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::>( + "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::>().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(_) + )) + ); +} diff --git a/crates/ruma-server-util/src/authorization.rs b/crates/ruma-server-util/src/authorization.rs index 8c7f02e6..c7645f57 100644 --- a/crates/ruma-server-util/src/authorization.rs +++ b/crates/ruma-server-util/src/authorization.rs @@ -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},"#)?; } From f9ae582b27fc6d5616809a84acb3265143663973 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Mon, 17 Jun 2024 19:43:03 +0200 Subject: [PATCH 07/15] chore: Make bytes a workspace dependency --- Cargo.toml | 1 + crates/ruma-client-api/Cargo.toml | 2 +- crates/ruma-client/Cargo.toml | 2 +- crates/ruma-common/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 3f00977f..6e0dcd36 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,6 +12,7 @@ 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" diff --git a/crates/ruma-client-api/Cargo.toml b/crates/ruma-client-api/Cargo.toml index 2814a038..01927db0 100644 --- a/crates/ruma-client-api/Cargo.toml +++ b/crates/ruma-client-api/Cargo.toml @@ -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"] } diff --git a/crates/ruma-client/Cargo.toml b/crates/ruma-client/Cargo.toml index 74358c9f..80c0f1ce 100644 --- a/crates/ruma-client/Cargo.toml +++ b/crates/ruma-client/Cargo.toml @@ -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 } diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index 5bfdd71f..f0e41ff9 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -56,7 +56,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 } From 586668806eef7306428335a6da8eaf709ea2c413 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Mon, 17 Jun 2024 19:46:09 +0200 Subject: [PATCH 08/15] client-api: Move some media types and helpers to ruma-common Without breaking changes. --- .../src/authenticated_media/get_content.rs | 10 ++++-- .../get_content_as_filename.rs | 6 ++-- .../get_content_thumbnail.rs | 9 +++-- crates/ruma-client-api/src/media.rs | 13 ------- .../ruma-client-api/src/media/get_content.rs | 6 ++-- .../src/media/get_content_as_filename.rs | 6 ++-- .../src/media/get_content_thumbnail.rs | 29 ++++------------ crates/ruma-common/src/lib.rs | 1 + crates/ruma-common/src/media.rs | 34 +++++++++++++++++++ 9 files changed, 61 insertions(+), 53 deletions(-) create mode 100644 crates/ruma-common/src/media.rs diff --git a/crates/ruma-client-api/src/authenticated_media/get_content.rs b/crates/ruma-client-api/src/authenticated_media/get_content.rs index efe12093..75296d04 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content.rs @@ -44,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, } @@ -70,7 +70,11 @@ pub mod v1 { 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. diff --git a/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs b/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs index ff9f6f64..ba142484 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content_as_filename.rs @@ -48,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, } @@ -78,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(), } } diff --git a/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs b/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs index 8e575120..2c5bb518 100644 --- a/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs +++ b/crates/ruma-client-api/src/authenticated_media/get_content_thumbnail.rs @@ -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, } } diff --git a/crates/ruma-client-api/src/media.rs b/crates/ruma-client-api/src/media.rs index 78729a7e..c1a48657 100644 --- a/crates/ruma-client-api/src/media.rs +++ b/crates/ruma-client-api/src/media.rs @@ -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 -} diff --git a/crates/ruma-client-api/src/media/get_content.rs b/crates/ruma-client-api/src/media/get_content.rs index dc79a7b6..0a9821d9 100644 --- a/crates/ruma-client-api/src/media/get_content.rs +++ b/crates/ruma-client-api/src/media/get_content.rs @@ -61,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, @@ -108,7 +108,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, } } diff --git a/crates/ruma-client-api/src/media/get_content_as_filename.rs b/crates/ruma-client-api/src/media/get_content_as_filename.rs index 63f3ca11..139974ce 100644 --- a/crates/ruma-client-api/src/media/get_content_as_filename.rs +++ b/crates/ruma-client-api/src/media/get_content_as_filename.rs @@ -65,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, @@ -113,7 +113,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, } } diff --git a/crates/ruma-client-api/src/media/get_content_thumbnail.rs b/crates/ruma-client-api/src/media/get_content_thumbnail.rs index 8437ef54..8537241c 100644 --- a/crates/ruma-client-api/src/media/get_content_thumbnail.rs +++ b/crates/ruma-client-api/src/media/get_content_thumbnail.rs @@ -11,14 +11,13 @@ pub mod v3 { use http::header::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, @@ -140,7 +139,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, } @@ -167,20 +166,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), - } } diff --git a/crates/ruma-common/src/lib.rs b/crates/ruma-common/src/lib.rs index 8a188c50..c0b9a684 100644 --- a/crates/ruma-common/src/lib.rs +++ b/crates/ruma-common/src/lib.rs @@ -26,6 +26,7 @@ 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; diff --git a/crates/ruma-common/src/media.rs b/crates/ruma-common/src/media.rs new file mode 100644 index 00000000..ff163d56 --- /dev/null +++ b/crates/ruma-common/src/media.rs @@ -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 +} From 9e8008f011293c67191199324076e04808fe7034 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 29 Jun 2024 11:12:15 +0200 Subject: [PATCH 09/15] chore: Make rand a workspace dependency --- Cargo.toml | 1 + crates/ruma-common/Cargo.toml | 2 +- crates/ruma-signatures/Cargo.toml | 2 +- crates/ruma-state-res/Cargo.toml | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6e0dcd36..6445ef4a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ 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" } diff --git a/crates/ruma-common/Cargo.toml b/crates/ruma-common/Cargo.toml index f0e41ff9..156c4cd1 100644 --- a/crates/ruma-common/Cargo.toml +++ b/crates/ruma-common/Cargo.toml @@ -64,7 +64,7 @@ indexmap = { version = "2.0.0", features = ["serde"] } js_int = { workspace = true, features = ["serde"] } konst = { version = "0.3.5", default-features = false, features = ["cmp", "iter", "parsing"], optional = true } percent-encoding = "2.1.0" -rand = { version = "0.8.3", optional = true } +rand = { workspace = true, optional = true } regex = { version = "1.5.6", default-features = false, features = ["std", "perf"] } ruma-identifiers-validation = { workspace = true } ruma-macros = { workspace = true } diff --git a/crates/ruma-signatures/Cargo.toml b/crates/ruma-signatures/Cargo.toml index d165514d..103875ef 100644 --- a/crates/ruma-signatures/Cargo.toml +++ b/crates/ruma-signatures/Cargo.toml @@ -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" diff --git a/crates/ruma-state-res/Cargo.toml b/crates/ruma-state-res/Cargo.toml index f4ee9f10..a97ac7a1 100644 --- a/crates/ruma-state-res/Cargo.toml +++ b/crates/ruma-state-res/Cargo.toml @@ -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" From e815eb760336c1c8500496c14dcfea1ea8f03b7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 29 Jun 2024 16:59:50 +0200 Subject: [PATCH 10/15] federation-api: Add support for authenticated media endpoints According to MSC3916 / Matrix 1.11. --- crates/ruma-common/src/api/error.rs | 40 ++ crates/ruma-federation-api/CHANGELOG.md | 4 + crates/ruma-federation-api/Cargo.toml | 10 +- .../src/authenticated_media.rs | 511 ++++++++++++++++++ .../src/authenticated_media/get_content.rs | 107 ++++ .../get_content_thumbnail.rs | 143 +++++ crates/ruma-federation-api/src/lib.rs | 1 + 7 files changed, 814 insertions(+), 2 deletions(-) create mode 100644 crates/ruma-federation-api/src/authenticated_media.rs create mode 100644 crates/ruma-federation-api/src/authenticated_media/get_content.rs create mode 100644 crates/ruma-federation-api/src/authenticated_media/get_content_thumbnail.rs diff --git a/crates/ruma-common/src/api/error.rs b/crates/ruma-common/src/api/error.rs index aa066678..d10190ab 100644 --- a/crates/ruma-common/src/api/error.rs +++ b/crates/ruma-common/src/api/error.rs @@ -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 for DeserializationError { @@ -294,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), } /// An error that happens when Ruma cannot understand a Matrix version. diff --git a/crates/ruma-federation-api/CHANGELOG.md b/crates/ruma-federation-api/CHANGELOG.md index 7cf199f8..edd0899d 100644 --- a/crates/ruma-federation-api/CHANGELOG.md +++ b/crates/ruma-federation-api/CHANGELOG.md @@ -1,5 +1,9 @@ # [unreleased] +Improvements: + +- Add support for authenticated media endpoints, according to MSC3916 / Matrix 1.11 + # 0.9.0 Breaking changes: diff --git a/crates/ruma-federation-api/Cargo.toml b/crates/ruma-federation-api/Cargo.toml index 7e225b92..11a671d9 100644 --- a/crates/ruma-federation-api/Cargo.toml +++ b/crates/ruma-federation-api/Cargo.toml @@ -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 } diff --git a/crates/ruma-federation-api/src/authenticated_media.rs b/crates/ruma-federation-api/src/authenticated_media.rs new file mode 100644 index 00000000..135d3424 --- /dev/null +++ b/crates/ruma-federation-api/src/authenticated_media.rs @@ -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, + + /// The content type of the file that was previously uploaded. + pub content_type: Option, + + /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the + /// file that was previously uploaded. + pub content_disposition: Option, +} + +impl Content { + /// Creates a new `Content` with the given bytes. + pub fn new(file: Vec) -> 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( + metadata: &ContentMetadata, + content: &FileOrLocation, +) -> Result, 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::(); + + 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>( + http_response: http::Response, +) -> Result< + (ContentMetadata, FileOrLocation), + ruma_common::api::error::FromHttpResponseError, +> { + 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::() + .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::>(&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::>(&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::>(&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(), "ȵ⌾Ⱦԩ💈Ňɠ"); + } +} diff --git a/crates/ruma-federation-api/src/authenticated_media/get_content.rs b/crates/ruma-federation-api/src/authenticated_media/get_content.rs new file mode 100644 index 00000000..5df6ba1c --- /dev/null +++ b/crates/ruma-federation-api/src/authenticated_media/get_content.rs @@ -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>( + http_response: http::Response, + ) -> Result> + { + 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( + self, + ) -> Result, ruma_common::api::error::IntoHttpError> { + crate::authenticated_media::try_into_multipart_mixed_response( + &self.metadata, + &self.content, + ) + } + } +} diff --git a/crates/ruma-federation-api/src/authenticated_media/get_content_thumbnail.rs b/crates/ruma-federation-api/src/authenticated_media/get_content_thumbnail.rs new file mode 100644 index 00000000..d373db1a --- /dev/null +++ b/crates/ruma-federation-api/src/authenticated_media/get_content_thumbnail.rs @@ -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, + + /// 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, + } + + 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>( + http_response: http::Response, + ) -> Result> + { + 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( + self, + ) -> Result, ruma_common::api::error::IntoHttpError> { + crate::authenticated_media::try_into_multipart_mixed_response( + &self.metadata, + &self.content, + ) + } + } +} diff --git a/crates/ruma-federation-api/src/lib.rs b/crates/ruma-federation-api/src/lib.rs index 4330b9f0..c48cdd25 100644 --- a/crates/ruma-federation-api/src/lib.rs +++ b/crates/ruma-federation-api/src/lib.rs @@ -12,6 +12,7 @@ use std::fmt; mod serde; +pub mod authenticated_media; pub mod authorization; pub mod backfill; pub mod device; From 14d7415f0d80aadf425c2384c0f348d1c03527c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Sat, 22 Jun 2024 11:38:31 +0200 Subject: [PATCH 11/15] client-api: Do not send request body for logout and logout_all Due to a clarification in the spec. --- crates/ruma-client-api/CHANGELOG.md | 2 + crates/ruma-client-api/src/session/logout.rs | 74 +++++++++++++++++-- .../ruma-client-api/src/session/logout_all.rs | 74 +++++++++++++++++-- 3 files changed, 134 insertions(+), 16 deletions(-) diff --git a/crates/ruma-client-api/CHANGELOG.md b/crates/ruma-client-api/CHANGELOG.md index 2606a168..3dc851b5 100644 --- a/crates/ruma-client-api/CHANGELOG.md +++ b/crates/ruma-client-api/CHANGELOG.md @@ -34,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 diff --git a/crates/ruma-client-api/src/session/logout.rs b/crates/ruma-client-api/src/session/logout.rs index 02c3a0ce..aae53243 100644 --- a/crates/ruma-client-api/src/session/logout.rs +++ b/crates/ruma-client-api/src/session/logout.rs @@ -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( + self, + base_url: &str, + access_token: ruma_common::api::SendAccessToken<'_>, + considering_versions: &'_ [ruma_common::api::MatrixVersion], + ) -> Result, 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( + request: http::Request, + _path_args: &[S], + ) -> Result + where + B: AsRef<[u8]>, + S: AsRef, + { + 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 { diff --git a/crates/ruma-client-api/src/session/logout_all.rs b/crates/ruma-client-api/src/session/logout_all.rs index 794f1e08..abbc7d63 100644 --- a/crates/ruma-client-api/src/session/logout_all.rs +++ b/crates/ruma-client-api/src/session/logout_all.rs @@ -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( + self, + base_url: &str, + access_token: ruma_common::api::SendAccessToken<'_>, + considering_versions: &'_ [ruma_common::api::MatrixVersion], + ) -> Result, 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( + request: http::Request, + _path_args: &[S], + ) -> Result + where + B: AsRef<[u8]>, + S: AsRef, + { + 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 { From 878d2b287b56212fcdec738e2aebe0de4701e817 Mon Sep 17 00:00:00 2001 From: Timo <16718859+toger5@users.noreply.github.com> Date: Mon, 29 Jul 2024 16:38:38 +0200 Subject: [PATCH 12/15] events: update future endpoints and rename future to delay To match the latest version of the MSC --- crates/ruma-client-api/src/delayed_events.rs | 37 ++++ .../delayed_message_event.rs} | 71 +++---- .../src/delayed_events/delayed_state_event.rs | 162 ++++++++++++++++ .../delayed_events/update_delayed_event.rs | 104 +++++++++++ crates/ruma-client-api/src/future.rs | 38 ---- .../src/future/send_future_state_event.rs | 175 ------------------ .../src/future/update_future.rs | 49 ----- crates/ruma-client-api/src/lib.rs | 4 +- 8 files changed, 330 insertions(+), 310 deletions(-) create mode 100644 crates/ruma-client-api/src/delayed_events.rs rename crates/ruma-client-api/src/{future/send_future_message_event.rs => delayed_events/delayed_message_event.rs} (61%) create mode 100644 crates/ruma-client-api/src/delayed_events/delayed_state_event.rs create mode 100644 crates/ruma-client-api/src/delayed_events/update_delayed_event.rs delete mode 100644 crates/ruma-client-api/src/future.rs delete mode 100644 crates/ruma-client-api/src/future/send_future_state_event.rs delete mode 100644 crates/ruma-client-api/src/future/update_future.rs diff --git a/crates/ruma-client-api/src/delayed_events.rs b/crates/ruma-client-api/src/delayed_events.rs new file mode 100644 index 00000000..b5b9e0c8 --- /dev/null +++ b/crates/ruma-client-api/src/delayed_events.rs @@ -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, + }, +} diff --git a/crates/ruma-client-api/src/future/send_future_message_event.rs b/crates/ruma-client-api/src/delayed_events/delayed_message_event.rs similarity index 61% rename from crates/ruma-client-api/src/future/send_future_message_event.rs rename to crates/ruma-client-api/src/delayed_events/delayed_message_event.rs index 402a1725..841ead10 100644 --- a/crates/ruma-client-api/src/future/send_future_message_event.rs +++ b/crates/ruma-client-api/src/delayed_events/delayed_message_event.rs @@ -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, + /// 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( room_id: OwnedRoomId, txn_id: OwnedTransactionId, - future_parameters: FutureParameters, + delay_parameters: DelayParameters, content: &T, ) -> serde_json::Result 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, ) -> 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, - ) -> 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()); diff --git a/crates/ruma-client-api/src/delayed_events/delayed_state_event.rs b/crates/ruma-client-api/src/delayed_events/delayed_state_event.rs new file mode 100644 index 00000000..6ea3d100 --- /dev/null +++ b/crates/ruma-client-api/src/delayed_events/delayed_state_event.rs @@ -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, + } + + /// 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( + room_id: OwnedRoomId, + state_key: String, + delay_parameters: DelayParameters, + content: &T, + ) -> serde_json::Result + 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, + ) -> 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) { + 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::(std::str::from_utf8(&body).unwrap()).unwrap() + ); + } + } +} diff --git a/crates/ruma-client-api/src/delayed_events/update_delayed_event.rs b/crates/ruma-client-api/src/delayed_events/update_delayed_event.rs new file mode 100644 index 00000000..17d966e4 --- /dev/null +++ b/crates/ruma-client-api/src/delayed_events/update_delayed_event.rs @@ -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> = + 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::(std::str::from_utf8(&body).unwrap()).unwrap() + ); + } + } +} diff --git a/crates/ruma-client-api/src/future.rs b/crates/ruma-client-api/src/future.rs deleted file mode 100644 index e2ee06ab..00000000 --- a/crates/ruma-client-api/src/future.rs +++ /dev/null @@ -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, - }, - - /// 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, - }, -} diff --git a/crates/ruma-client-api/src/future/send_future_state_event.rs b/crates/ruma-client-api/src/future/send_future_state_event.rs deleted file mode 100644 index 0d439be3..00000000 --- a/crates/ruma-client-api/src/future/send_future_state_event.rs +++ /dev/null @@ -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, - } - - /// 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, - } - - 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( - room_id: OwnedRoomId, - state_key: String, - future_parameters: FutureParameters, - content: &T, - ) -> serde_json::Result - 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, - ) -> 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, - ) -> 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> = 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::(std::str::from_utf8(&body).unwrap()).unwrap() - ); - } - } -} diff --git a/crates/ruma-client-api/src/future/update_future.rs b/crates/ruma-client-api/src/future/update_future.rs deleted file mode 100644 index 419214b6..00000000 --- a/crates/ruma-client-api/src/future/update_future.rs +++ /dev/null @@ -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 { - 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 {} - } - } -} diff --git a/crates/ruma-client-api/src/lib.rs b/crates/ruma-client-api/src/lib.rs index 71ba4812..594e4be5 100644 --- a/crates/ruma-client-api/src/lib.rs +++ b/crates/ruma-client-api/src/lib.rs @@ -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; From 6087f7efbb1113c4bbfc88deb511113dbb39eda5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Mon, 29 Jul 2024 14:59:54 +0200 Subject: [PATCH 13/15] ci: Bump nightly version --- .github/workflows/ci.yml | 2 +- rust-toolchain.toml | 2 +- xtask/src/main.rs | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d489c40..93ce0367 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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: diff --git a/rust-toolchain.toml b/rust-toolchain.toml index 647f9a1c..a2ab784c 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -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"] diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c917863d..057bc5e4 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -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; From 04654f88337449ba6e8406a2267ab606a54a9d63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Commaille?= Date: Mon, 29 Jul 2024 15:00:21 +0200 Subject: [PATCH 14/15] chore: Fix new clippy warnings --- crates/ruma-common/src/push/action.rs | 8 ++++---- crates/ruma-common/src/push/condition.rs | 2 +- crates/ruma-events/tests/it/redacted.rs | 4 ++-- .../src/send_event_notification.rs | 6 +----- crates/ruma-signatures/src/functions.rs | 16 ++++++++-------- 5 files changed, 16 insertions(+), 20 deletions(-) diff --git a/crates/ruma-common/src/push/action.rs b/crates/ruma-common/src/push/action.rs index ea57cc8d..cf4c0b99 100644 --- a/crates/ruma-common/src/push/action.rs +++ b/crates/ruma-common/src/push/action.rs @@ -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 }) ); } diff --git a/crates/ruma-common/src/push/condition.rs b/crates/ruma-common/src/push/condition.rs index 08df971c..9f0910aa 100644 --- a/crates/ruma-common/src/push/condition.rs +++ b/crates/ruma-common/src/push/condition.rs @@ -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 = vec![]; diff --git a/crates/ruma-events/tests/it/redacted.rs b/crates/ruma-events/tests/it/redacted.rs index 57c4baa9..9dbc5b1d 100644 --- a/crates/ruma-events/tests/it/redacted.rs +++ b/crates/ruma-events/tests/it/redacted.rs @@ -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); } diff --git a/crates/ruma-push-gateway-api/src/send_event_notification.rs b/crates/ruma-push-gateway-api/src/send_event_notification.rs index 3d800a68..b349447b 100644 --- a/crates/ruma-push-gateway-api/src/send_event_notification.rs +++ b/crates/ruma-push-gateway-api/src/send_event_notification.rs @@ -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)); } diff --git a/crates/ruma-signatures/src/functions.rs b/crates/ruma-signatures/src/functions.rs index 6cff34d3..a4bea08a 100644 --- a/crates/ruma-signatures/src/functions.rs +++ b/crates/ruma-signatures/src/functions.rs @@ -40,7 +40,7 @@ static REFERENCE_HASH_FIELDS_TO_REMOVE: &[&str] = &["signatures", "unsigned"]; /// # Parameters /// /// * entity_id: The identifier of the entity creating the signature. Generally this means a -/// homeserver, e.g. "example.com". +/// homeserver, e.g. "example.com". /// * key_pair: A cryptographic key pair used to sign the JSON. /// * object: A JSON object to sign according and append a signature to. /// @@ -167,9 +167,9 @@ pub fn canonical_json(object: &CanonicalJsonObject) -> Result { /// # Parameters /// /// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. -/// Generally, entity identifiers are server names — the host/IP/port of a homeserver (e.g. -/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. -/// "ed25519:1") then map to their respective public keys. +/// Generally, entity identifiers are server names — the host/IP/port of a homeserver (e.g. +/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. +/// "ed25519:1") then map to their respective public keys. /// * object: The JSON object that was signed. /// /// # Errors @@ -355,7 +355,7 @@ pub fn reference_hash( /// # Parameters /// /// * entity_id: The identifier of the entity creating the signature. Generally this means a -/// homeserver, e.g. "example.com". +/// homeserver, e.g. "example.com". /// * key_pair: A cryptographic key pair used to sign the event. /// * object: A JSON object to be hashed and signed according to the Matrix specification. /// @@ -486,9 +486,9 @@ where /// # Parameters /// /// * public_key_map: A map from entity identifiers to a map from key identifiers to public keys. -/// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g. -/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. -/// "ed25519:1") then map to their respective public keys. +/// Generally, entity identifiers are server names—the host/IP/port of a homeserver (e.g. +/// "example.com") for which a signature must be verified. Key identifiers for each server (e.g. +/// "ed25519:1") then map to their respective public keys. /// * object: The JSON object of the event that was signed. /// * version: Room version of the given event /// From 82417e394076440089cd8ada87485d9a44cc4ba0 Mon Sep 17 00:00:00 2001 From: Andrew Ferrazzutti Date: Sat, 10 Aug 2024 03:41:46 -0400 Subject: [PATCH 15/15] events: Loosen type of call member event state keys Allow MSC3401-style call membership state events to have state keys that are arbitrary strings, and not just pure MXIDs. This allows for state keys that are suffixed with the sender's device ID, as well as ones prefixed by an underscore to bypass auth rules over state keys that start with a `@` but do not strictly equal an MXID. --- crates/ruma-events/src/call/member.rs | 31 ++++++++++++++++++++------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/crates/ruma-events/src/call/member.rs b/crates/ruma-events/src/call/member.rs index 35751851..bb05b74f 100644 --- a/crates/ruma-events/src/call/member.rs +++ b/crates/ruma-events/src/call/member.rs @@ -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 =