1use std::ops::Deref;
6
7#[cfg(feature = "server")]
8use ruma_common::api::OutgoingBody;
9#[cfg(feature = "client")]
10use ruma_common::api::error::HeaderDeserializationError;
11use ruma_common::http_headers::ContentDisposition;
12use serde::{Deserialize, Serialize};
13
14pub mod get_content;
15pub mod get_content_thumbnail;
16
17const MULTIPART_MIXED: &str = "multipart/mixed";
19#[cfg(feature = "client")]
21const MAX_HEADERS_COUNT: usize = 32;
22#[cfg(feature = "server")]
24const GENERATED_BOUNDARY_LENGTH: usize = 30;
25
26#[derive(Debug, Clone, Default, Serialize, Deserialize)]
28#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
29pub struct ContentMetadata {}
30
31impl ContentMetadata {
32 pub fn new() -> Self {
34 Self {}
35 }
36}
37
38#[derive(Debug, Clone)]
40#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
41pub enum FileOrLocation {
42 File(Content),
44
45 Location(String),
47}
48
49#[derive(Debug, Clone)]
51#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
52pub struct Content {
53 pub file: Vec<u8>,
55
56 pub content_type: Option<String>,
58
59 pub content_disposition: Option<ContentDisposition>,
62}
63
64impl Content {
65 pub fn new(
67 file: Vec<u8>,
68 content_type: String,
69 content_disposition: ContentDisposition,
70 ) -> Self {
71 Self {
72 file,
73 content_type: Some(content_type),
74 content_disposition: Some(content_disposition),
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81struct MultipartMixedBoundary(String);
82
83#[cfg(feature = "server")]
84impl MultipartMixedBoundary {
85 fn new() -> Self {
87 use rand::RngExt as _;
88
89 Self(
90 rand::rng()
91 .sample_iter(&rand::distr::Alphanumeric)
92 .map(char::from)
93 .take(GENERATED_BOUNDARY_LENGTH)
94 .collect(),
95 )
96 }
97
98 fn content_type(&self) -> String {
100 format!("{MULTIPART_MIXED}; boundary={}", self.0)
101 }
102
103 fn write_separator(&self, buf: &mut impl std::io::Write) {
105 let _ = write!(buf, "\r\n--{}\r\n", self.0);
106 }
107
108 fn write_end(&self, buf: &mut impl std::io::Write) {
110 let _ = write!(buf, "\r\n--{}", self.0);
111 }
112}
113
114#[cfg(feature = "client")]
115impl MultipartMixedBoundary {
116 fn parse_http_response_headers(
118 http_response: &http::Response<&[u8]>,
119 ) -> Result<Self, HeaderDeserializationError> {
120 let body_content_type = http_response
121 .headers()
122 .get(http::header::CONTENT_TYPE)
123 .ok_or_else(|| HeaderDeserializationError::MissingHeader("Content-Type".to_owned()))?
124 .to_str()?
125 .parse::<mime::Mime>()
126 .map_err(|e| HeaderDeserializationError::InvalidHeader(e.into()))?;
127
128 if !body_content_type.essence_str().eq_ignore_ascii_case(MULTIPART_MIXED) {
129 return Err(HeaderDeserializationError::InvalidHeaderValue {
130 header: "Content-Type".to_owned(),
131 expected: MULTIPART_MIXED.to_owned(),
132 unexpected: body_content_type.essence_str().to_owned(),
133 });
134 }
135
136 Ok(Self(
137 body_content_type
138 .get_param("boundary")
139 .ok_or(HeaderDeserializationError::MissingMultipartBoundary)?
140 .as_str()
141 .to_owned(),
142 ))
143 }
144}
145
146impl Deref for MultipartMixedBoundary {
147 type Target = str;
148
149 fn deref(&self) -> &Self::Target {
150 &self.0
151 }
152}
153
154#[doc(hidden)]
156#[derive(Debug, Clone)]
157pub struct ResponseBody {
158 metadata: ContentMetadata,
159 content: FileOrLocation,
160 #[cfg_attr(not(feature = "server"), expect(dead_code))]
162 boundary: MultipartMixedBoundary,
163}
164
165#[cfg(feature = "server")]
166impl ResponseBody {
167 fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self {
171 Self { metadata, content, boundary: MultipartMixedBoundary::new() }
172 }
173
174 fn try_into_http_response(
176 self,
177 ) -> Result<http::Response<Self>, ruma_common::api::error::IntoHttpError> {
178 let content_type = self.boundary.content_type();
179
180 Ok(http::Response::builder().header(http::header::CONTENT_TYPE, content_type).body(self)?)
181 }
182}
183
184#[cfg(feature = "server")]
185impl OutgoingBody for ResponseBody {
186 type Error = ruma_common::api::error::IntoHttpError;
187
188 fn try_into_buf<T: Default + bytes::BufMut>(self) -> Result<T, Self::Error> {
189 use std::io::Write as _;
190
191 let mut body_writer = T::default().writer();
192 let Self { metadata, content, boundary } = &self;
193
194 boundary.write_separator(&mut body_writer);
196
197 let _ = write!(
199 body_writer,
200 "{}: {}\r\n\r\n",
201 http::header::CONTENT_TYPE,
202 mime::APPLICATION_JSON
203 );
204
205 serde_json::to_writer(&mut body_writer, metadata)?;
207
208 boundary.write_separator(&mut body_writer);
210
211 match content {
213 FileOrLocation::File(content) => {
214 let content_type = content
216 .content_type
217 .as_deref()
218 .unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref());
219 let _ = write!(body_writer, "{}: {content_type}\r\n", http::header::CONTENT_TYPE);
220
221 if let Some(content_disposition) = &content.content_disposition {
222 let _ = write!(
223 body_writer,
224 "{}: {content_disposition}\r\n",
225 http::header::CONTENT_DISPOSITION
226 );
227 }
228
229 let _ = body_writer.write_all(b"\r\n");
231
232 let _ = body_writer.write_all(&content.file);
234 }
235 FileOrLocation::Location(location) => {
236 let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION);
238 }
239 }
240
241 boundary.write_end(&mut body_writer);
243
244 Ok(body_writer.into_inner())
245 }
246}
247
248#[cfg(feature = "client")]
249impl ResponseBody {
250 fn try_from_http_response(
252 http_response: http::Response<&[u8]>,
253 ) -> Result<Self, ruma_common::api::error::DeserializationError> {
254 use ruma_common::api::error::MultipartMixedDeserializationError;
255
256 let boundary = MultipartMixedBoundary::parse_http_response_headers(&http_response)?;
258
259 let body = http_response.body();
261
262 let mut full_boundary = Vec::with_capacity(boundary.len() + 4);
263 full_boundary.extend_from_slice(b"\r\n--");
264 full_boundary.extend_from_slice(boundary.as_bytes());
265 let full_boundary_no_crlf = full_boundary.strip_prefix(b"\r\n").unwrap();
266
267 let mut boundaries = memchr::memmem::find_iter(body, &full_boundary);
268
269 let metadata_start = if body.starts_with(full_boundary_no_crlf) {
270 full_boundary_no_crlf.len()
273 } else {
274 boundaries.next().ok_or_else(|| {
275 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
276 })? + full_boundary.len()
277 };
278 let metadata_end = boundaries.next().ok_or_else(|| {
279 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
280 })?;
281
282 let (_raw_metadata_headers, serialized_metadata) =
283 parse_multipart_body_part(body, metadata_start, metadata_end)?;
284
285 let metadata = serde_json::from_slice(serialized_metadata)?;
288
289 let content_start = metadata_end + full_boundary.len();
291 let content_end = boundaries.next().ok_or_else(|| {
292 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 1 }
293 })?;
294
295 let (raw_content_headers, file) =
296 parse_multipart_body_part(body, content_start, content_end)?;
297
298 let mut content_headers = [httparse::EMPTY_HEADER; MAX_HEADERS_COUNT];
300 httparse::parse_headers(raw_content_headers, &mut content_headers)
301 .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?;
302
303 let mut location = None;
304 let mut content_type = None;
305 let mut content_disposition = None;
306 for header in content_headers {
307 if header.name.is_empty() {
308 break;
310 }
311
312 if header.name == http::header::LOCATION {
313 location =
314 Some(String::from_utf8(header.value.to_vec()).map_err(|e| {
315 MultipartMixedDeserializationError::InvalidHeader(e.into())
316 })?);
317
318 break;
320 } else if header.name == http::header::CONTENT_TYPE {
321 content_type =
322 Some(String::from_utf8(header.value.to_vec()).map_err(|e| {
323 MultipartMixedDeserializationError::InvalidHeader(e.into())
324 })?);
325 } else if header.name == http::header::CONTENT_DISPOSITION {
326 content_disposition =
327 Some(ContentDisposition::try_from(header.value).map_err(|e| {
328 MultipartMixedDeserializationError::InvalidHeader(e.into())
329 })?);
330 }
331 }
332
333 let content = if let Some(location) = location {
334 FileOrLocation::Location(location)
335 } else {
336 FileOrLocation::File(Content {
337 file: file.to_owned(),
338 content_type,
339 content_disposition,
340 })
341 };
342
343 Ok(Self { metadata, content, boundary })
344 }
345}
346
347#[cfg(feature = "client")]
352fn parse_multipart_body_part(
353 bytes: &[u8],
354 start: usize,
355 end: usize,
356) -> Result<(&[u8], &[u8]), ruma_common::api::error::MultipartMixedDeserializationError> {
357 use ruma_common::api::error::MultipartMixedDeserializationError;
358
359 let headers_start = memchr::memchr(b'\n', &bytes[start..end])
362 .expect("the end boundary contains a newline")
363 + start
364 + 1;
365
366 let mut line_start = headers_start;
368 let mut line_end;
369
370 loop {
371 line_end = memchr::memchr(b'\n', &bytes[line_start..end])
372 .ok_or(MultipartMixedDeserializationError::MissingBodyPartInnerSeparator)?
373 + line_start
374 + 1;
375
376 if matches!(&bytes[line_start..line_end], b"\r\n" | b"\n") {
377 break;
378 }
379
380 line_start = line_end;
381 }
382
383 Ok((&bytes[headers_start..line_start], &bytes[line_end..end]))
384}
385
386#[cfg(all(test, feature = "client", feature = "server"))]
387mod tests {
388 use assert_matches2::assert_matches;
389 use ruma_common::{
390 api::OutgoingBody,
391 http_headers::{ContentDisposition, ContentDispositionType},
392 };
393
394 use super::{Content, ContentMetadata, FileOrLocation, ResponseBody};
395
396 #[test]
397 fn multipart_mixed_content_ascii_filename_conversions() {
398 let file = "s⌽me UTF-8 Ťext".as_bytes();
399 let content_type = "text/plain";
400 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
401 .with_filename(Some("filename.txt".to_owned()));
402
403 let outgoing_metadata = ContentMetadata::new();
404 let outgoing_content = FileOrLocation::File(Content {
405 file: file.to_vec(),
406 content_type: Some(content_type.to_owned()),
407 content_disposition: Some(content_disposition.clone()),
408 });
409
410 let (parts, body) = ResponseBody::new(outgoing_metadata, outgoing_content)
411 .try_into_http_response()
412 .unwrap()
413 .into_parts();
414 let body = body.try_into_buf::<Vec<u8>>().unwrap();
415 let response = http::Response::from_parts(parts, body.as_slice());
416
417 let ResponseBody { content: incoming_content, .. } =
418 ResponseBody::try_from_http_response(response).unwrap();
419
420 assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
421 assert_eq!(incoming_content.file, file);
422 assert_eq!(incoming_content.content_type.unwrap(), content_type);
423 assert_eq!(incoming_content.content_disposition, Some(content_disposition));
424 }
425
426 #[test]
427 fn multipart_mixed_content_utf8_filename_conversions() {
428 let file = "s⌽me UTF-8 Ťext".as_bytes();
429 let content_type = "text/plain";
430 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
431 .with_filename(Some("fȈlƩnąmǝ.txt".to_owned()));
432
433 let outgoing_metadata = ContentMetadata::new();
434 let outgoing_content = FileOrLocation::File(Content {
435 file: file.to_vec(),
436 content_type: Some(content_type.to_owned()),
437 content_disposition: Some(content_disposition.clone()),
438 });
439
440 let (parts, body) = ResponseBody::new(outgoing_metadata, outgoing_content)
441 .try_into_http_response()
442 .unwrap()
443 .into_parts();
444 let body = body.try_into_buf::<Vec<u8>>().unwrap();
445 let response = http::Response::from_parts(parts, body.as_slice());
446
447 let ResponseBody { content: incoming_content, .. } =
448 ResponseBody::try_from_http_response(response).unwrap();
449
450 assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
451 assert_eq!(incoming_content.file, file);
452 assert_eq!(incoming_content.content_type.unwrap(), content_type);
453 assert_eq!(incoming_content.content_disposition, Some(content_disposition));
454 }
455
456 #[test]
457 fn multipart_mixed_location_conversions() {
458 let location = "https://server.local/media/filename.txt";
459
460 let outgoing_metadata = ContentMetadata::new();
461 let outgoing_content = FileOrLocation::Location(location.to_owned());
462
463 let (parts, body) = ResponseBody::new(outgoing_metadata, outgoing_content)
464 .try_into_http_response()
465 .unwrap()
466 .into_parts();
467 let body = body.try_into_buf::<Vec<u8>>().unwrap();
468 let response = http::Response::from_parts(parts, body.as_slice());
469
470 let ResponseBody { content: incoming_content, .. } =
471 ResponseBody::try_from_http_response(response).unwrap();
472
473 assert_matches!(incoming_content, FileOrLocation::Location(incoming_location));
474 assert_eq!(incoming_location, location);
475 }
476
477 #[test]
478 fn multipart_mixed_deserialize_invalid() {
479 let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
481 let response = http::Response::builder()
482 .header(http::header::CONTENT_TYPE, "multipart/mixed")
483 .body(body.as_slice())
484 .unwrap();
485
486 ResponseBody::try_from_http_response(response).unwrap_err();
487
488 let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
490 let response = http::Response::builder()
491 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=012345")
492 .body(body.as_slice())
493 .unwrap();
494
495 ResponseBody::try_from_http_response(response).unwrap_err();
496
497 let body =
499 b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text";
500 let response = http::Response::builder()
501 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
502 .body(body.as_slice())
503 .unwrap();
504
505 ResponseBody::try_from_http_response(response).unwrap_err();
506
507 let body = b"\r\n--abcdef\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
509 let response = http::Response::builder()
510 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
511 .body(body.as_slice())
512 .unwrap();
513
514 ResponseBody::try_from_http_response(response).unwrap_err();
515
516 let body = b"\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--";
518 let response = http::Response::builder()
519 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
520 .body(body.as_slice())
521 .unwrap();
522
523 ResponseBody::try_from_http_response(response).unwrap_err();
524
525 let body = b"foo--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
527 let response = http::Response::builder()
528 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
529 .body(body.as_slice())
530 .unwrap();
531
532 ResponseBody::try_from_http_response(response).unwrap_err();
533 }
534
535 #[test]
536 fn multipart_mixed_deserialize_valid() {
537 let body = b"\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--";
539 let response = http::Response::builder()
540 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
541 .body(body.as_slice())
542 .unwrap();
543
544 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
545
546 assert_matches!(content, FileOrLocation::File(file_content));
547 assert_eq!(file_content.file, b"some plain text");
548 assert_eq!(file_content.content_type.unwrap(), "text/plain");
549 assert_eq!(file_content.content_disposition, None);
550
551 let body = b"\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--";
553 let response = http::Response::builder()
554 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
555 .body(body.as_slice())
556 .unwrap();
557
558 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
559
560 assert_matches!(content, FileOrLocation::File(file_content));
561 assert_eq!(file_content.file, b"some plain text");
562 assert_eq!(file_content.content_type.unwrap(), "text/plain");
563 let content_disposition = file_content.content_disposition.unwrap();
564 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
565 assert_eq!(content_disposition.filename.unwrap(), "my_file.txt");
566
567 let body = b" \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-- ";
569 let response = http::Response::builder()
570 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
571 .body(body.as_slice())
572 .unwrap();
573
574 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
575
576 assert_matches!(content, FileOrLocation::File(file_content));
577 assert_eq!(file_content.file, b"some plain text");
578 assert_eq!(file_content.content_type.unwrap(), "text/plain");
579 assert_eq!(file_content.content_disposition, None);
580
581 let body = b"\r\n--abcdef\ncontent-type: application/json\n\n{}\r\n--abcdef\ncontent-type: text/plain \n\nsome plain text\r\n--abcdef--";
583 let response = http::Response::builder()
584 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
585 .body(body.as_slice())
586 .unwrap();
587
588 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
589
590 assert_matches!(content, FileOrLocation::File(file_content));
591 assert_eq!(file_content.file, b"some plain text");
592 assert_eq!(file_content.content_type.unwrap(), "text/plain");
593 assert_eq!(file_content.content_disposition, None);
594
595 let body = b"--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
597 let response = http::Response::builder()
598 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
599 .body(body.as_slice())
600 .unwrap();
601
602 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
603
604 assert_matches!(content, FileOrLocation::File(file_content));
605 assert_eq!(file_content.file, b"some plain text");
606 assert_eq!(file_content.content_type, None);
607 assert_eq!(file_content.content_disposition, None);
608
609 let body =
612 b"foo--abcdef\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
613 let response = http::Response::builder()
614 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
615 .body(body.as_slice())
616 .unwrap();
617
618 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
619
620 assert_matches!(content, FileOrLocation::File(file_content));
621 assert_eq!(file_content.file, b"some plain text");
622 assert_eq!(file_content.content_type, None);
623 assert_eq!(file_content.content_disposition, None);
624
625 let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
627 let response = http::Response::builder()
628 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
629 .body(body.as_slice())
630 .unwrap();
631
632 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
633
634 assert_matches!(content, FileOrLocation::File(file_content));
635 assert_eq!(file_content.file, b"some plain text");
636 assert_eq!(file_content.content_type, None);
637 assert_eq!(file_content.content_disposition, None);
638
639 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--";
641 let response = http::Response::builder()
642 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
643 .body(body.as_bytes())
644 .unwrap();
645
646 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
647
648 assert_matches!(content, FileOrLocation::File(file_content));
649 assert_eq!(file_content.file, b"some plain text");
650 assert_eq!(file_content.content_type.unwrap(), "text/plain");
651 let content_disposition = file_content.content_disposition.unwrap();
652 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
653 assert_eq!(content_disposition.filename.unwrap(), "ȵ⌾Ⱦԩ💈Ňɠ");
654 }
655}