federation-api: Add support for authenticated media endpoints
According to MSC3916 / Matrix 1.11.
This commit is contained in:
parent
9e8008f011
commit
e815eb7603
@ -243,6 +243,10 @@ pub enum DeserializationError {
|
||||
/// Header value deserialization failed.
|
||||
#[error(transparent)]
|
||||
Header(#[from] HeaderDeserializationError),
|
||||
|
||||
/// Deserialization of `multipart/mixed` response failed.
|
||||
#[error(transparent)]
|
||||
MultipartMixed(#[from] MultipartMixedDeserializationError),
|
||||
}
|
||||
|
||||
impl From<std::convert::Infallible> for DeserializationError {
|
||||
@ -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<dyn std::error::Error + Send + Sync + 'static>),
|
||||
}
|
||||
|
||||
/// An error that happens when Ruma cannot understand a Matrix version.
|
||||
|
@ -1,5 +1,9 @@
|
||||
# [unreleased]
|
||||
|
||||
Improvements:
|
||||
|
||||
- Add support for authenticated media endpoints, according to MSC3916 / Matrix 1.11
|
||||
|
||||
# 0.9.0
|
||||
|
||||
Breaking changes:
|
||||
|
@ -19,8 +19,8 @@ all-features = true
|
||||
# them to an empty string in deserialization.
|
||||
compat-empty-string-null = []
|
||||
|
||||
client = []
|
||||
server = []
|
||||
client = ["dep:httparse", "dep:memchr"]
|
||||
server = ["dep:bytes", "dep:rand"]
|
||||
unstable-exhaustive-types = []
|
||||
unstable-msc2448 = []
|
||||
unstable-msc3618 = []
|
||||
@ -30,7 +30,13 @@ unstable-msc4125 = []
|
||||
unstable-unspecified = []
|
||||
|
||||
[dependencies]
|
||||
bytes = { workspace = true, optional = true }
|
||||
http = { workspace = true }
|
||||
httparse = { version = "1.9.0", optional = true }
|
||||
js_int = { workspace = true, features = ["serde"] }
|
||||
memchr = { version = "2.7.0", optional = true }
|
||||
mime = { version = "0.3.0" }
|
||||
rand = { workspace = true, optional = true }
|
||||
ruma-common = { workspace = true, features = ["api"] }
|
||||
ruma-events = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
|
511
crates/ruma-federation-api/src/authenticated_media.rs
Normal file
511
crates/ruma-federation-api/src/authenticated_media.rs
Normal file
@ -0,0 +1,511 @@
|
||||
//! Authenticated endpoints for the content repository, according to [MSC3916].
|
||||
//!
|
||||
//! [MSC3916]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
|
||||
|
||||
use ruma_common::http_headers::ContentDisposition;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub mod get_content;
|
||||
pub mod get_content_thumbnail;
|
||||
|
||||
/// The `multipart/mixed` mime "essence".
|
||||
const MULTIPART_MIXED: &str = "multipart/mixed";
|
||||
/// The maximum number of headers to parse in a body part.
|
||||
const MAX_HEADERS_COUNT: usize = 32;
|
||||
/// The length of the generated boundary.
|
||||
const GENERATED_BOUNDARY_LENGTH: usize = 30;
|
||||
|
||||
/// The metadata of a file from the content repository.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
|
||||
pub struct ContentMetadata {}
|
||||
|
||||
impl ContentMetadata {
|
||||
/// Creates a new empty `ContentMetadata`.
|
||||
pub fn new() -> Self {
|
||||
Self {}
|
||||
}
|
||||
}
|
||||
|
||||
/// A file from the content repository or the location where it can be found.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
|
||||
pub enum FileOrLocation {
|
||||
/// The content of the file.
|
||||
File(Content),
|
||||
|
||||
/// The file is at the given URL.
|
||||
Location(String),
|
||||
}
|
||||
|
||||
/// The content of a file from the content repository.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
|
||||
pub struct Content {
|
||||
/// The content of the file as bytes.
|
||||
pub file: Vec<u8>,
|
||||
|
||||
/// The content type of the file that was previously uploaded.
|
||||
pub content_type: Option<String>,
|
||||
|
||||
/// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
|
||||
/// file that was previously uploaded.
|
||||
pub content_disposition: Option<ContentDisposition>,
|
||||
}
|
||||
|
||||
impl Content {
|
||||
/// Creates a new `Content` with the given bytes.
|
||||
pub fn new(file: Vec<u8>) -> Self {
|
||||
Self { file, content_type: None, content_disposition: None }
|
||||
}
|
||||
}
|
||||
|
||||
/// Serialize the given metadata and content into a `http::Response` `multipart/mixed` body.
|
||||
///
|
||||
/// Returns a tuple containing the boundary used
|
||||
#[cfg(feature = "server")]
|
||||
fn try_into_multipart_mixed_response<T: Default + bytes::BufMut>(
|
||||
metadata: &ContentMetadata,
|
||||
content: &FileOrLocation,
|
||||
) -> Result<http::Response<T>, ruma_common::api::error::IntoHttpError> {
|
||||
use std::io::Write as _;
|
||||
|
||||
use rand::Rng as _;
|
||||
|
||||
let boundary = rand::thread_rng()
|
||||
.sample_iter(&rand::distributions::Alphanumeric)
|
||||
.map(char::from)
|
||||
.take(GENERATED_BOUNDARY_LENGTH)
|
||||
.collect::<String>();
|
||||
|
||||
let mut body_writer = T::default().writer();
|
||||
|
||||
// Add first boundary separator and header for the metadata.
|
||||
let _ = write!(
|
||||
body_writer,
|
||||
"\r\n--{boundary}\r\n{}: {}\r\n\r\n",
|
||||
http::header::CONTENT_TYPE,
|
||||
mime::APPLICATION_JSON
|
||||
);
|
||||
|
||||
// Add serialized metadata.
|
||||
serde_json::to_writer(&mut body_writer, metadata)?;
|
||||
|
||||
// Add second boundary separator.
|
||||
let _ = write!(body_writer, "\r\n--{boundary}\r\n");
|
||||
|
||||
// Add content.
|
||||
match content {
|
||||
FileOrLocation::File(content) => {
|
||||
// Add headers.
|
||||
let content_type =
|
||||
content.content_type.as_deref().unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref());
|
||||
let _ = write!(body_writer, "{}: {content_type}\r\n", http::header::CONTENT_TYPE);
|
||||
|
||||
if let Some(content_disposition) = &content.content_disposition {
|
||||
let _ = write!(
|
||||
body_writer,
|
||||
"{}: {content_disposition}\r\n",
|
||||
http::header::CONTENT_DISPOSITION
|
||||
);
|
||||
}
|
||||
|
||||
// Add empty line separator after headers.
|
||||
let _ = body_writer.write_all(b"\r\n");
|
||||
|
||||
// Add bytes.
|
||||
let _ = body_writer.write_all(&content.file);
|
||||
}
|
||||
FileOrLocation::Location(location) => {
|
||||
// Only add location header and empty line separator.
|
||||
let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION);
|
||||
}
|
||||
}
|
||||
|
||||
// Add final boundary.
|
||||
let _ = write!(body_writer, "\r\n--{boundary}--");
|
||||
|
||||
let content_type = format!("{MULTIPART_MIXED}; boundary={boundary}");
|
||||
let body = body_writer.into_inner();
|
||||
|
||||
Ok(http::Response::builder().header(http::header::CONTENT_TYPE, content_type).body(body)?)
|
||||
}
|
||||
|
||||
/// Deserialize the given metadata and content from a `http::Response` with a `multipart/mixed`
|
||||
/// body.
|
||||
#[cfg(feature = "client")]
|
||||
fn try_from_multipart_mixed_response<T: AsRef<[u8]>>(
|
||||
http_response: http::Response<T>,
|
||||
) -> Result<
|
||||
(ContentMetadata, FileOrLocation),
|
||||
ruma_common::api::error::FromHttpResponseError<ruma_common::api::error::MatrixError>,
|
||||
> {
|
||||
use ruma_common::api::error::{HeaderDeserializationError, MultipartMixedDeserializationError};
|
||||
|
||||
// First, get the boundary from the content type header.
|
||||
let body_content_type = http_response
|
||||
.headers()
|
||||
.get(http::header::CONTENT_TYPE)
|
||||
.ok_or_else(|| HeaderDeserializationError::MissingHeader("Content-Type".to_owned()))?
|
||||
.to_str()?
|
||||
.parse::<mime::Mime>()
|
||||
.map_err(|e| HeaderDeserializationError::InvalidHeader(e.into()))?;
|
||||
|
||||
if !body_content_type.essence_str().eq_ignore_ascii_case(MULTIPART_MIXED) {
|
||||
return Err(HeaderDeserializationError::InvalidHeaderValue {
|
||||
header: "Content-Type".to_owned(),
|
||||
expected: MULTIPART_MIXED.to_owned(),
|
||||
unexpected: body_content_type.essence_str().to_owned(),
|
||||
}
|
||||
.into());
|
||||
}
|
||||
|
||||
let boundary = body_content_type
|
||||
.get_param("boundary")
|
||||
.ok_or(HeaderDeserializationError::MissingMultipartBoundary)?
|
||||
.as_str()
|
||||
.as_bytes();
|
||||
|
||||
// Split the body with the boundary.
|
||||
let body = http_response.body().as_ref();
|
||||
|
||||
let mut full_boundary = Vec::with_capacity(boundary.len() + 4);
|
||||
full_boundary.extend_from_slice(b"\r\n--");
|
||||
full_boundary.extend_from_slice(boundary);
|
||||
|
||||
let mut boundaries = memchr::memmem::find_iter(body, &full_boundary);
|
||||
|
||||
let metadata_start = boundaries.next().ok_or_else(|| {
|
||||
MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
|
||||
})? + full_boundary.len();
|
||||
let metadata_end = boundaries.next().ok_or_else(|| {
|
||||
MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
|
||||
})?;
|
||||
|
||||
let (_raw_metadata_headers, serialized_metadata) =
|
||||
parse_multipart_body_part(body, metadata_start, metadata_end)?;
|
||||
|
||||
// Don't search for anything in the headers, just deserialize the content that should be JSON.
|
||||
let metadata = serde_json::from_slice(serialized_metadata)?;
|
||||
|
||||
// Look at the part containing the media content now.
|
||||
let content_start = metadata_end + full_boundary.len();
|
||||
let content_end = boundaries.next().ok_or_else(|| {
|
||||
MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 1 }
|
||||
})?;
|
||||
|
||||
let (raw_content_headers, file) = parse_multipart_body_part(body, content_start, content_end)?;
|
||||
|
||||
// Parse the headers to retrieve the content type and content disposition.
|
||||
let mut content_headers = [httparse::EMPTY_HEADER; MAX_HEADERS_COUNT];
|
||||
httparse::parse_headers(raw_content_headers, &mut content_headers)
|
||||
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?;
|
||||
|
||||
let mut location = None;
|
||||
let mut content_type = None;
|
||||
let mut content_disposition = None;
|
||||
for header in content_headers {
|
||||
if header.name.is_empty() {
|
||||
// This is a empty header, we have reached the end of the parsed headers.
|
||||
break;
|
||||
}
|
||||
|
||||
if header.name == http::header::LOCATION {
|
||||
location = Some(
|
||||
String::from_utf8(header.value.to_vec())
|
||||
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
|
||||
);
|
||||
|
||||
// This is the only header we need, stop parsing.
|
||||
break;
|
||||
} else if header.name == http::header::CONTENT_TYPE {
|
||||
content_type = Some(
|
||||
String::from_utf8(header.value.to_vec())
|
||||
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
|
||||
);
|
||||
} else if header.name == http::header::CONTENT_DISPOSITION {
|
||||
content_disposition = Some(
|
||||
ContentDisposition::try_from(header.value)
|
||||
.map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let content = if let Some(location) = location {
|
||||
FileOrLocation::Location(location)
|
||||
} else {
|
||||
FileOrLocation::File(Content { file: file.to_owned(), content_type, content_disposition })
|
||||
};
|
||||
|
||||
Ok((metadata, content))
|
||||
}
|
||||
|
||||
/// Parse the multipart body part in the given bytes, starting and ending at the given positions.
|
||||
///
|
||||
/// Returns a `(headers_bytes, content_bytes)` tuple. Returns an error if the separation between the
|
||||
/// headers and the content could not be found.
|
||||
#[cfg(feature = "client")]
|
||||
fn parse_multipart_body_part(
|
||||
bytes: &[u8],
|
||||
start: usize,
|
||||
end: usize,
|
||||
) -> Result<(&[u8], &[u8]), ruma_common::api::error::MultipartMixedDeserializationError> {
|
||||
use ruma_common::api::error::MultipartMixedDeserializationError;
|
||||
|
||||
// The part should start with a newline after the boundary. We need to ignore characters before
|
||||
// it in case of extra whitespaces, and for compatibility it might not have a CR.
|
||||
let headers_start = memchr::memchr(b'\n', &bytes[start..end])
|
||||
.expect("the end boundary contains a newline")
|
||||
+ start
|
||||
+ 1;
|
||||
|
||||
// Let's find an empty line now.
|
||||
let mut line_start = headers_start;
|
||||
let mut line_end;
|
||||
|
||||
loop {
|
||||
line_end = memchr::memchr(b'\n', &bytes[line_start..end])
|
||||
.ok_or(MultipartMixedDeserializationError::MissingBodyPartInnerSeparator)?
|
||||
+ line_start
|
||||
+ 1;
|
||||
|
||||
if matches!(&bytes[line_start..line_end], b"\r\n" | b"\n") {
|
||||
break;
|
||||
}
|
||||
|
||||
line_start = line_end;
|
||||
}
|
||||
|
||||
Ok((&bytes[headers_start..line_start], &bytes[line_end..end]))
|
||||
}
|
||||
|
||||
#[cfg(all(test, feature = "client", feature = "server"))]
|
||||
mod tests {
|
||||
use assert_matches2::assert_matches;
|
||||
use ruma_common::http_headers::{ContentDisposition, ContentDispositionType};
|
||||
|
||||
use super::{
|
||||
try_from_multipart_mixed_response, try_into_multipart_mixed_response, Content,
|
||||
ContentMetadata, FileOrLocation,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn multipart_mixed_content_ascii_filename_conversions() {
|
||||
let file = "s⌽me UTF-8 Ťext".as_bytes();
|
||||
let content_type = "text/plain";
|
||||
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
|
||||
.with_filename(Some("filename.txt".to_owned()));
|
||||
|
||||
let outgoing_metadata = ContentMetadata::new();
|
||||
let outgoing_content = FileOrLocation::File(Content {
|
||||
file: file.to_vec(),
|
||||
content_type: Some(content_type.to_owned()),
|
||||
content_disposition: Some(content_disposition.clone()),
|
||||
});
|
||||
|
||||
let response =
|
||||
try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
|
||||
.unwrap();
|
||||
|
||||
let (_incoming_metadata, incoming_content) =
|
||||
try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
|
||||
assert_eq!(incoming_content.file, file);
|
||||
assert_eq!(incoming_content.content_type.unwrap(), content_type);
|
||||
assert_eq!(incoming_content.content_disposition, Some(content_disposition));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_mixed_content_utf8_filename_conversions() {
|
||||
let file = "s⌽me UTF-8 Ťext".as_bytes();
|
||||
let content_type = "text/plain";
|
||||
let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
|
||||
.with_filename(Some("fȈlƩnąmǝ.txt".to_owned()));
|
||||
|
||||
let outgoing_metadata = ContentMetadata::new();
|
||||
let outgoing_content = FileOrLocation::File(Content {
|
||||
file: file.to_vec(),
|
||||
content_type: Some(content_type.to_owned()),
|
||||
content_disposition: Some(content_disposition.clone()),
|
||||
});
|
||||
|
||||
let response =
|
||||
try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
|
||||
.unwrap();
|
||||
|
||||
let (_incoming_metadata, incoming_content) =
|
||||
try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
|
||||
assert_eq!(incoming_content.file, file);
|
||||
assert_eq!(incoming_content.content_type.unwrap(), content_type);
|
||||
assert_eq!(incoming_content.content_disposition, Some(content_disposition));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_mixed_location_conversions() {
|
||||
let location = "https://server.local/media/filename.txt";
|
||||
|
||||
let outgoing_metadata = ContentMetadata::new();
|
||||
let outgoing_content = FileOrLocation::Location(location.to_owned());
|
||||
|
||||
let response =
|
||||
try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
|
||||
.unwrap();
|
||||
|
||||
let (_incoming_metadata, incoming_content) =
|
||||
try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(incoming_content, FileOrLocation::Location(incoming_location));
|
||||
assert_eq!(incoming_location, location);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_mixed_deserialize_invalid() {
|
||||
// Missing boundary in headers.
|
||||
let body = "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
try_from_multipart_mixed_response(response).unwrap_err();
|
||||
|
||||
// Wrong boundary.
|
||||
let body =
|
||||
"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=012345")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
try_from_multipart_mixed_response(response).unwrap_err();
|
||||
|
||||
// Missing boundary in body.
|
||||
let body =
|
||||
"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
try_from_multipart_mixed_response(response).unwrap_err();
|
||||
|
||||
// Missing header and content empty line separator in body part.
|
||||
let body =
|
||||
"\r\n--abcdef\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
try_from_multipart_mixed_response(response).unwrap_err();
|
||||
|
||||
// Control character in header.
|
||||
let body =
|
||||
"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\nContent-Disposition: inline; filename=\"my\nfile\"\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
try_from_multipart_mixed_response(response).unwrap_err();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multipart_mixed_deserialize_valid() {
|
||||
// Simple.
|
||||
let body =
|
||||
"\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(content, FileOrLocation::File(file_content));
|
||||
assert_eq!(file_content.file, b"some plain text");
|
||||
assert_eq!(file_content.content_type.unwrap(), "text/plain");
|
||||
assert_eq!(file_content.content_disposition, None);
|
||||
|
||||
// Case-insensitive headers.
|
||||
let body =
|
||||
"\r\n--abcdef\r\nCONTENT-type: application/json\r\n\r\n{}\r\n--abcdef\r\nCONTENT-TYPE: text/plain\r\ncoNtenT-disPosItioN: attachment; filename=my_file.txt\r\n\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(content, FileOrLocation::File(file_content));
|
||||
assert_eq!(file_content.file, b"some plain text");
|
||||
assert_eq!(file_content.content_type.unwrap(), "text/plain");
|
||||
let content_disposition = file_content.content_disposition.unwrap();
|
||||
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
|
||||
assert_eq!(content_disposition.filename.unwrap(), "my_file.txt");
|
||||
|
||||
// Extra whitespace.
|
||||
let body =
|
||||
" \r\n--abcdef\r\ncontent-type: application/json \r\n\r\n {} \r\n--abcdef\r\ncontent-type: text/plain \r\n\r\nsome plain text\r\n--abcdef-- ";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(content, FileOrLocation::File(file_content));
|
||||
assert_eq!(file_content.file, b"some plain text");
|
||||
assert_eq!(file_content.content_type.unwrap(), "text/plain");
|
||||
assert_eq!(file_content.content_disposition, None);
|
||||
|
||||
// Missing CR except in boundaries.
|
||||
let body =
|
||||
"\r\n--abcdef\ncontent-type: application/json\n\n{}\r\n--abcdef\ncontent-type: text/plain \n\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(content, FileOrLocation::File(file_content));
|
||||
assert_eq!(file_content.file, b"some plain text");
|
||||
assert_eq!(file_content.content_type.unwrap(), "text/plain");
|
||||
assert_eq!(file_content.content_disposition, None);
|
||||
|
||||
// No body part headers.
|
||||
let body = "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(content, FileOrLocation::File(file_content));
|
||||
assert_eq!(file_content.file, b"some plain text");
|
||||
assert_eq!(file_content.content_type, None);
|
||||
assert_eq!(file_content.content_disposition, None);
|
||||
|
||||
// Raw UTF-8 filename (some kind of compatibility with multipart/form-data).
|
||||
let body =
|
||||
"\r\n--abcdef\r\ncontent-type: application/json\r\n\r\n{}\r\n--abcdef\r\ncontent-type: text/plain\r\ncontent-disposition: inline; filename=\"ȵ⌾Ⱦԩ💈Ňɠ\"\r\n\r\nsome plain text\r\n--abcdef--";
|
||||
let response = http::Response::builder()
|
||||
.header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
|
||||
.body(body)
|
||||
.unwrap();
|
||||
|
||||
let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
|
||||
|
||||
assert_matches!(content, FileOrLocation::File(file_content));
|
||||
assert_eq!(file_content.file, b"some plain text");
|
||||
assert_eq!(file_content.content_type.unwrap(), "text/plain");
|
||||
let content_disposition = file_content.content_disposition.unwrap();
|
||||
assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
|
||||
assert_eq!(content_disposition.filename.unwrap(), "ȵ⌾Ⱦԩ💈Ňɠ");
|
||||
}
|
||||
}
|
@ -0,0 +1,107 @@
|
||||
//! `GET /_matrix/federation/*/media/download/{mediaId}`
|
||||
//!
|
||||
//! Retrieve content from the media store.
|
||||
|
||||
pub mod v1 {
|
||||
//! `/v1/` ([spec])
|
||||
//!
|
||||
//! [spec]: https://spec.matrix.org/latest/server-server-api/#get_matrixfederationv1mediadownloadmediaid
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use ruma_common::{
|
||||
api::{request, Metadata},
|
||||
metadata,
|
||||
};
|
||||
|
||||
use crate::authenticated_media::{ContentMetadata, FileOrLocation};
|
||||
|
||||
const METADATA: Metadata = metadata! {
|
||||
method: GET,
|
||||
rate_limited: true,
|
||||
authentication: ServerSignatures,
|
||||
history: {
|
||||
unstable => "/_matrix/federation/unstable/org.matrix.msc3916.v2/media/download/:media_id",
|
||||
1.11 => "/_matrix/federation/v1/media/download/:media_id",
|
||||
}
|
||||
};
|
||||
|
||||
/// Request type for the `get_content` endpoint.
|
||||
#[request]
|
||||
pub struct Request {
|
||||
/// The media ID from the `mxc://` URI (the path component).
|
||||
#[ruma_api(path)]
|
||||
pub media_id: String,
|
||||
|
||||
/// The maximum duration that the client is willing to wait to start receiving data, in the
|
||||
/// case that the content has not yet been uploaded.
|
||||
///
|
||||
/// The default value is 20 seconds.
|
||||
#[ruma_api(query)]
|
||||
#[serde(
|
||||
with = "ruma_common::serde::duration::ms",
|
||||
default = "ruma_common::media::default_download_timeout",
|
||||
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
|
||||
)]
|
||||
pub timeout_ms: Duration,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Creates a new `Request` with the given media ID.
|
||||
pub fn new(media_id: String) -> Self {
|
||||
Self { media_id, timeout_ms: ruma_common::media::default_download_timeout() }
|
||||
}
|
||||
}
|
||||
|
||||
/// Response type for the `get_content` endpoint.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
|
||||
pub struct Response {
|
||||
/// The metadata of the media.
|
||||
pub metadata: ContentMetadata,
|
||||
|
||||
/// The content of the media.
|
||||
pub content: FileOrLocation,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Creates a new `Response` with the given metadata and content.
|
||||
pub fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self {
|
||||
Self { metadata, content }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl ruma_common::api::IncomingResponse for Response {
|
||||
type EndpointError = ruma_common::api::error::MatrixError;
|
||||
|
||||
fn try_from_http_response<T: AsRef<[u8]>>(
|
||||
http_response: http::Response<T>,
|
||||
) -> Result<Self, ruma_common::api::error::FromHttpResponseError<Self::EndpointError>>
|
||||
{
|
||||
use ruma_common::api::EndpointError;
|
||||
|
||||
if http_response.status().as_u16() < 400 {
|
||||
let (metadata, content) =
|
||||
crate::authenticated_media::try_from_multipart_mixed_response(http_response)?;
|
||||
Ok(Self { metadata, content })
|
||||
} else {
|
||||
Err(ruma_common::api::error::FromHttpResponseError::Server(
|
||||
ruma_common::api::error::MatrixError::from_http_response(http_response),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl ruma_common::api::OutgoingResponse for Response {
|
||||
fn try_into_http_response<T: Default + bytes::BufMut>(
|
||||
self,
|
||||
) -> Result<http::Response<T>, ruma_common::api::error::IntoHttpError> {
|
||||
crate::authenticated_media::try_into_multipart_mixed_response(
|
||||
&self.metadata,
|
||||
&self.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
//! `GET /_matrix/federation/*/media/thumbnail/{mediaId}`
|
||||
//!
|
||||
//! Get a thumbnail of content from the media repository.
|
||||
|
||||
pub mod v1 {
|
||||
//! `/v1/` ([spec])
|
||||
//!
|
||||
//! [spec]: https://spec.matrix.org/latest/server-server-api/#get_matrixfederationv1mediathumbnailmediaid
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use js_int::UInt;
|
||||
use ruma_common::{
|
||||
api::{request, Metadata},
|
||||
media::Method,
|
||||
metadata,
|
||||
};
|
||||
|
||||
use crate::authenticated_media::{ContentMetadata, FileOrLocation};
|
||||
|
||||
const METADATA: Metadata = metadata! {
|
||||
method: GET,
|
||||
rate_limited: true,
|
||||
authentication: ServerSignatures,
|
||||
history: {
|
||||
unstable => "/_matrix/federation/unstable/org.matrix.msc3916.v2/media/thumbnail/:media_id",
|
||||
1.11 => "/_matrix/federation/v1/media/thumbnail/:media_id",
|
||||
}
|
||||
};
|
||||
|
||||
/// Request type for the `get_content_thumbnail` endpoint.
|
||||
#[request]
|
||||
pub struct Request {
|
||||
/// The media ID from the `mxc://` URI (the path component).
|
||||
#[ruma_api(path)]
|
||||
pub media_id: String,
|
||||
|
||||
/// The desired resizing method.
|
||||
#[ruma_api(query)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub method: Option<Method>,
|
||||
|
||||
/// The *desired* width of the thumbnail.
|
||||
///
|
||||
/// The actual thumbnail may not match the size specified.
|
||||
#[ruma_api(query)]
|
||||
pub width: UInt,
|
||||
|
||||
/// The *desired* height of the thumbnail.
|
||||
///
|
||||
/// The actual thumbnail may not match the size specified.
|
||||
#[ruma_api(query)]
|
||||
pub height: UInt,
|
||||
|
||||
/// The maximum duration that the client is willing to wait to start receiving data, in the
|
||||
/// case that the content has not yet been uploaded.
|
||||
///
|
||||
/// The default value is 20 seconds.
|
||||
#[ruma_api(query)]
|
||||
#[serde(
|
||||
with = "ruma_common::serde::duration::ms",
|
||||
default = "ruma_common::media::default_download_timeout",
|
||||
skip_serializing_if = "ruma_common::media::is_default_download_timeout"
|
||||
)]
|
||||
pub timeout_ms: Duration,
|
||||
|
||||
/// Whether the server should return an animated thumbnail.
|
||||
///
|
||||
/// When `Some(true)`, the server should return an animated thumbnail if possible and
|
||||
/// supported. When `Some(false)`, the server must not return an animated
|
||||
/// thumbnail. When `None`, the server should not return an animated thumbnail.
|
||||
#[ruma_api(query)]
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub animated: Option<bool>,
|
||||
}
|
||||
|
||||
impl Request {
|
||||
/// Creates a new `Request` with the given media ID, desired thumbnail width
|
||||
/// and desired thumbnail height.
|
||||
pub fn new(media_id: String, width: UInt, height: UInt) -> Self {
|
||||
Self {
|
||||
media_id,
|
||||
method: None,
|
||||
width,
|
||||
height,
|
||||
timeout_ms: ruma_common::media::default_download_timeout(),
|
||||
animated: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Response type for the `get_content_thumbnail` endpoint.
|
||||
#[derive(Debug, Clone)]
|
||||
#[cfg_attr(not(feature = "unstable-exhaustive-types"), non_exhaustive)]
|
||||
pub struct Response {
|
||||
/// The metadata of the thumbnail.
|
||||
pub metadata: ContentMetadata,
|
||||
|
||||
/// The content of the thumbnail.
|
||||
pub content: FileOrLocation,
|
||||
}
|
||||
|
||||
impl Response {
|
||||
/// Creates a new `Response` with the given metadata and content.
|
||||
pub fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self {
|
||||
Self { metadata, content }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "client")]
|
||||
impl ruma_common::api::IncomingResponse for Response {
|
||||
type EndpointError = ruma_common::api::error::MatrixError;
|
||||
|
||||
fn try_from_http_response<T: AsRef<[u8]>>(
|
||||
http_response: http::Response<T>,
|
||||
) -> Result<Self, ruma_common::api::error::FromHttpResponseError<Self::EndpointError>>
|
||||
{
|
||||
use ruma_common::api::EndpointError;
|
||||
|
||||
if http_response.status().as_u16() < 400 {
|
||||
let (metadata, content) =
|
||||
crate::authenticated_media::try_from_multipart_mixed_response(http_response)?;
|
||||
Ok(Self { metadata, content })
|
||||
} else {
|
||||
Err(ruma_common::api::error::FromHttpResponseError::Server(
|
||||
ruma_common::api::error::MatrixError::from_http_response(http_response),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
impl ruma_common::api::OutgoingResponse for Response {
|
||||
fn try_into_http_response<T: Default + bytes::BufMut>(
|
||||
self,
|
||||
) -> Result<http::Response<T>, ruma_common::api::error::IntoHttpError> {
|
||||
crate::authenticated_media::try_into_multipart_mixed_response(
|
||||
&self.metadata,
|
||||
&self.content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
@ -12,6 +12,7 @@ use std::fmt;
|
||||
|
||||
mod serde;
|
||||
|
||||
pub mod authenticated_media;
|
||||
pub mod authorization;
|
||||
pub mod backfill;
|
||||
pub mod device;
|
||||
|
Loading…
x
Reference in New Issue
Block a user