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) -> http::HeaderValue {
100 format!("{MULTIPART_MIXED}; boundary={}", self.0)
101 .try_into()
102 .expect("content type should only contain visible ASCII characters")
103 }
104
105 fn write_separator(&self, buf: &mut impl std::io::Write) {
107 let _ = write!(buf, "\r\n--{}\r\n", self.0);
108 }
109
110 fn write_end(&self, buf: &mut impl std::io::Write) {
112 let _ = write!(buf, "\r\n--{}", self.0);
113 }
114}
115
116#[cfg(feature = "client")]
117impl MultipartMixedBoundary {
118 fn parse_http_response_headers(
120 http_response: &http::Response<&[u8]>,
121 ) -> Result<Self, HeaderDeserializationError> {
122 let body_content_type = http_response
123 .headers()
124 .get(http::header::CONTENT_TYPE)
125 .ok_or_else(|| HeaderDeserializationError::MissingHeader("Content-Type".to_owned()))?
126 .to_str()?
127 .parse::<mime::Mime>()
128 .map_err(|e| HeaderDeserializationError::InvalidHeader(e.into()))?;
129
130 if !body_content_type.essence_str().eq_ignore_ascii_case(MULTIPART_MIXED) {
131 return Err(HeaderDeserializationError::InvalidHeaderValue {
132 header: "Content-Type".to_owned(),
133 expected: MULTIPART_MIXED.to_owned(),
134 unexpected: body_content_type.essence_str().to_owned(),
135 });
136 }
137
138 Ok(Self(
139 body_content_type
140 .get_param("boundary")
141 .ok_or(HeaderDeserializationError::MissingMultipartBoundary)?
142 .as_str()
143 .to_owned(),
144 ))
145 }
146}
147
148impl Deref for MultipartMixedBoundary {
149 type Target = str;
150
151 fn deref(&self) -> &Self::Target {
152 &self.0
153 }
154}
155
156#[doc(hidden)]
158#[derive(Debug, Clone)]
159pub struct ResponseBody {
160 metadata: ContentMetadata,
161 content: FileOrLocation,
162 #[cfg_attr(not(feature = "server"), expect(dead_code))]
164 boundary: MultipartMixedBoundary,
165}
166
167#[cfg(feature = "server")]
168impl ResponseBody {
169 fn new(metadata: ContentMetadata, content: FileOrLocation) -> Self {
173 Self { metadata, content, boundary: MultipartMixedBoundary::new() }
174 }
175}
176
177#[cfg(feature = "server")]
178impl OutgoingBody for ResponseBody {
179 type Error = ruma_common::api::error::IntoHttpError;
180
181 fn content_type(&self) -> Option<http::HeaderValue> {
182 Some(self.boundary.content_type())
183 }
184
185 fn try_into_buf<T: Default + bytes::BufMut>(self) -> Result<T, Self::Error> {
186 use std::io::Write as _;
187
188 let mut body_writer = T::default().writer();
189 let Self { metadata, content, boundary } = &self;
190
191 boundary.write_separator(&mut body_writer);
193
194 let _ = write!(
196 body_writer,
197 "{}: {}\r\n\r\n",
198 http::header::CONTENT_TYPE,
199 mime::APPLICATION_JSON
200 );
201
202 serde_json::to_writer(&mut body_writer, metadata)?;
204
205 boundary.write_separator(&mut body_writer);
207
208 match content {
210 FileOrLocation::File(content) => {
211 let content_type = content
213 .content_type
214 .as_deref()
215 .unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref());
216 let _ = write!(body_writer, "{}: {content_type}\r\n", http::header::CONTENT_TYPE);
217
218 if let Some(content_disposition) = &content.content_disposition {
219 let _ = write!(
220 body_writer,
221 "{}: {content_disposition}\r\n",
222 http::header::CONTENT_DISPOSITION
223 );
224 }
225
226 let _ = body_writer.write_all(b"\r\n");
228
229 let _ = body_writer.write_all(&content.file);
231 }
232 FileOrLocation::Location(location) => {
233 let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION);
235 }
236 }
237
238 boundary.write_end(&mut body_writer);
240
241 Ok(body_writer.into_inner())
242 }
243}
244
245#[cfg(feature = "client")]
246impl ResponseBody {
247 fn try_from_http_response(
249 http_response: http::Response<&[u8]>,
250 ) -> Result<Self, ruma_common::api::error::DeserializationError> {
251 use ruma_common::api::error::MultipartMixedDeserializationError;
252
253 let boundary = MultipartMixedBoundary::parse_http_response_headers(&http_response)?;
255
256 let body = http_response.body();
258
259 let mut full_boundary = Vec::with_capacity(boundary.len() + 4);
260 full_boundary.extend_from_slice(b"\r\n--");
261 full_boundary.extend_from_slice(boundary.as_bytes());
262 let full_boundary_no_crlf = full_boundary.strip_prefix(b"\r\n").unwrap();
263
264 let mut boundaries = memchr::memmem::find_iter(body, &full_boundary);
265
266 let metadata_start = if body.starts_with(full_boundary_no_crlf) {
267 full_boundary_no_crlf.len()
270 } else {
271 boundaries.next().ok_or_else(|| {
272 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
273 })? + full_boundary.len()
274 };
275 let metadata_end = boundaries.next().ok_or_else(|| {
276 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
277 })?;
278
279 let (_raw_metadata_headers, serialized_metadata) =
280 parse_multipart_body_part(body, metadata_start, metadata_end)?;
281
282 let metadata = serde_json::from_slice(serialized_metadata)?;
285
286 let content_start = metadata_end + full_boundary.len();
288 let content_end = boundaries.next().ok_or_else(|| {
289 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 1 }
290 })?;
291
292 let (raw_content_headers, file) =
293 parse_multipart_body_part(body, content_start, content_end)?;
294
295 let mut content_headers = [httparse::EMPTY_HEADER; MAX_HEADERS_COUNT];
297 httparse::parse_headers(raw_content_headers, &mut content_headers)
298 .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?;
299
300 let mut location = None;
301 let mut content_type = None;
302 let mut content_disposition = None;
303 for header in content_headers {
304 if header.name.is_empty() {
305 break;
307 }
308
309 if header.name == http::header::LOCATION {
310 location =
311 Some(String::from_utf8(header.value.to_vec()).map_err(|e| {
312 MultipartMixedDeserializationError::InvalidHeader(e.into())
313 })?);
314
315 break;
317 } else if header.name == http::header::CONTENT_TYPE {
318 content_type =
319 Some(String::from_utf8(header.value.to_vec()).map_err(|e| {
320 MultipartMixedDeserializationError::InvalidHeader(e.into())
321 })?);
322 } else if header.name == http::header::CONTENT_DISPOSITION {
323 content_disposition =
324 Some(ContentDisposition::try_from(header.value).map_err(|e| {
325 MultipartMixedDeserializationError::InvalidHeader(e.into())
326 })?);
327 }
328 }
329
330 let content = if let Some(location) = location {
331 FileOrLocation::Location(location)
332 } else {
333 FileOrLocation::File(Content {
334 file: file.to_owned(),
335 content_type,
336 content_disposition,
337 })
338 };
339
340 Ok(Self { metadata, content, boundary })
341 }
342}
343
344#[cfg(feature = "client")]
349fn parse_multipart_body_part(
350 bytes: &[u8],
351 start: usize,
352 end: usize,
353) -> Result<(&[u8], &[u8]), ruma_common::api::error::MultipartMixedDeserializationError> {
354 use ruma_common::api::error::MultipartMixedDeserializationError;
355
356 let headers_start = memchr::memchr(b'\n', &bytes[start..end])
359 .expect("the end boundary contains a newline")
360 + start
361 + 1;
362
363 let mut line_start = headers_start;
365 let mut line_end;
366
367 loop {
368 line_end = memchr::memchr(b'\n', &bytes[line_start..end])
369 .ok_or(MultipartMixedDeserializationError::MissingBodyPartInnerSeparator)?
370 + line_start
371 + 1;
372
373 if matches!(&bytes[line_start..line_end], b"\r\n" | b"\n") {
374 break;
375 }
376
377 line_start = line_end;
378 }
379
380 Ok((&bytes[headers_start..line_start], &bytes[line_end..end]))
381}
382
383#[cfg(all(test, feature = "client", feature = "server"))]
384mod tests {
385 use assert_matches2::assert_matches;
386 use ruma_common::{
387 api::OutgoingBody,
388 http_headers::{ContentDisposition, ContentDispositionType},
389 };
390
391 use super::{Content, ContentMetadata, FileOrLocation, ResponseBody};
392
393 #[test]
394 fn multipart_mixed_content_ascii_filename_conversions() {
395 let file = "s⌽me UTF-8 Ťext".as_bytes();
396 let content_type = "text/plain";
397 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
398 .with_filename(Some("filename.txt".to_owned()));
399
400 let outgoing_metadata = ContentMetadata::new();
401 let outgoing_content = FileOrLocation::File(Content {
402 file: file.to_vec(),
403 content_type: Some(content_type.to_owned()),
404 content_disposition: Some(content_disposition.clone()),
405 });
406
407 let body = ResponseBody::new(outgoing_metadata, outgoing_content);
408 let multipart_content_type = body.content_type().unwrap();
409 let body = body.try_into_buf::<Vec<u8>>().unwrap();
410
411 let response = http::Response::builder()
412 .header(http::header::CONTENT_TYPE, multipart_content_type)
413 .body(body.as_slice())
414 .unwrap();
415
416 let ResponseBody { content: incoming_content, .. } =
417 ResponseBody::try_from_http_response(response).unwrap();
418
419 assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
420 assert_eq!(incoming_content.file, file);
421 assert_eq!(incoming_content.content_type.unwrap(), content_type);
422 assert_eq!(incoming_content.content_disposition, Some(content_disposition));
423 }
424
425 #[test]
426 fn multipart_mixed_content_utf8_filename_conversions() {
427 let file = "s⌽me UTF-8 Ťext".as_bytes();
428 let content_type = "text/plain";
429 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
430 .with_filename(Some("fȈlƩnąmǝ.txt".to_owned()));
431
432 let outgoing_metadata = ContentMetadata::new();
433 let outgoing_content = FileOrLocation::File(Content {
434 file: file.to_vec(),
435 content_type: Some(content_type.to_owned()),
436 content_disposition: Some(content_disposition.clone()),
437 });
438
439 let body = ResponseBody::new(outgoing_metadata, outgoing_content);
440 let multipart_content_type = body.content_type().unwrap();
441 let body = body.try_into_buf::<Vec<u8>>().unwrap();
442
443 let response = http::Response::builder()
444 .header(http::header::CONTENT_TYPE, multipart_content_type)
445 .body(body.as_slice())
446 .unwrap();
447
448 let ResponseBody { content: incoming_content, .. } =
449 ResponseBody::try_from_http_response(response).unwrap();
450
451 assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
452 assert_eq!(incoming_content.file, file);
453 assert_eq!(incoming_content.content_type.unwrap(), content_type);
454 assert_eq!(incoming_content.content_disposition, Some(content_disposition));
455 }
456
457 #[test]
458 fn multipart_mixed_location_conversions() {
459 let location = "https://server.local/media/filename.txt";
460
461 let outgoing_metadata = ContentMetadata::new();
462 let outgoing_content = FileOrLocation::Location(location.to_owned());
463
464 let body = ResponseBody::new(outgoing_metadata, outgoing_content);
465 let multipart_content_type = body.content_type().unwrap();
466 let body = body.try_into_buf::<Vec<u8>>().unwrap();
467
468 let response = http::Response::builder()
469 .header(http::header::CONTENT_TYPE, multipart_content_type)
470 .body(body.as_slice())
471 .unwrap();
472
473 let ResponseBody { content: incoming_content, .. } =
474 ResponseBody::try_from_http_response(response).unwrap();
475
476 assert_matches!(incoming_content, FileOrLocation::Location(incoming_location));
477 assert_eq!(incoming_location, location);
478 }
479
480 #[test]
481 fn multipart_mixed_deserialize_invalid() {
482 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--";
484 let response = http::Response::builder()
485 .header(http::header::CONTENT_TYPE, "multipart/mixed")
486 .body(body.as_slice())
487 .unwrap();
488
489 ResponseBody::try_from_http_response(response).unwrap_err();
490
491 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--";
493 let response = http::Response::builder()
494 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=012345")
495 .body(body.as_slice())
496 .unwrap();
497
498 ResponseBody::try_from_http_response(response).unwrap_err();
499
500 let body =
502 b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text";
503 let response = http::Response::builder()
504 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
505 .body(body.as_slice())
506 .unwrap();
507
508 ResponseBody::try_from_http_response(response).unwrap_err();
509
510 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--";
512 let response = http::Response::builder()
513 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
514 .body(body.as_slice())
515 .unwrap();
516
517 ResponseBody::try_from_http_response(response).unwrap_err();
518
519 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--";
521 let response = http::Response::builder()
522 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
523 .body(body.as_slice())
524 .unwrap();
525
526 ResponseBody::try_from_http_response(response).unwrap_err();
527
528 let body = b"foo--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
530 let response = http::Response::builder()
531 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
532 .body(body.as_slice())
533 .unwrap();
534
535 ResponseBody::try_from_http_response(response).unwrap_err();
536 }
537
538 #[test]
539 fn multipart_mixed_deserialize_valid() {
540 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--";
542 let response = http::Response::builder()
543 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
544 .body(body.as_slice())
545 .unwrap();
546
547 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
548
549 assert_matches!(content, FileOrLocation::File(file_content));
550 assert_eq!(file_content.file, b"some plain text");
551 assert_eq!(file_content.content_type.unwrap(), "text/plain");
552 assert_eq!(file_content.content_disposition, None);
553
554 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--";
556 let response = http::Response::builder()
557 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
558 .body(body.as_slice())
559 .unwrap();
560
561 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
562
563 assert_matches!(content, FileOrLocation::File(file_content));
564 assert_eq!(file_content.file, b"some plain text");
565 assert_eq!(file_content.content_type.unwrap(), "text/plain");
566 let content_disposition = file_content.content_disposition.unwrap();
567 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
568 assert_eq!(content_disposition.filename.unwrap(), "my_file.txt");
569
570 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-- ";
572 let response = http::Response::builder()
573 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
574 .body(body.as_slice())
575 .unwrap();
576
577 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
578
579 assert_matches!(content, FileOrLocation::File(file_content));
580 assert_eq!(file_content.file, b"some plain text");
581 assert_eq!(file_content.content_type.unwrap(), "text/plain");
582 assert_eq!(file_content.content_disposition, None);
583
584 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--";
586 let response = http::Response::builder()
587 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
588 .body(body.as_slice())
589 .unwrap();
590
591 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
592
593 assert_matches!(content, FileOrLocation::File(file_content));
594 assert_eq!(file_content.file, b"some plain text");
595 assert_eq!(file_content.content_type.unwrap(), "text/plain");
596 assert_eq!(file_content.content_disposition, None);
597
598 let body = b"--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
600 let response = http::Response::builder()
601 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
602 .body(body.as_slice())
603 .unwrap();
604
605 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
606
607 assert_matches!(content, FileOrLocation::File(file_content));
608 assert_eq!(file_content.file, b"some plain text");
609 assert_eq!(file_content.content_type, None);
610 assert_eq!(file_content.content_disposition, None);
611
612 let body =
615 b"foo--abcdef\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
616 let response = http::Response::builder()
617 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
618 .body(body.as_slice())
619 .unwrap();
620
621 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
622
623 assert_matches!(content, FileOrLocation::File(file_content));
624 assert_eq!(file_content.file, b"some plain text");
625 assert_eq!(file_content.content_type, None);
626 assert_eq!(file_content.content_disposition, None);
627
628 let body = b"\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
630 let response = http::Response::builder()
631 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
632 .body(body.as_slice())
633 .unwrap();
634
635 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
636
637 assert_matches!(content, FileOrLocation::File(file_content));
638 assert_eq!(file_content.file, b"some plain text");
639 assert_eq!(file_content.content_type, None);
640 assert_eq!(file_content.content_disposition, None);
641
642 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--";
644 let response = http::Response::builder()
645 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
646 .body(body.as_bytes())
647 .unwrap();
648
649 let ResponseBody { content, .. } = ResponseBody::try_from_http_response(response).unwrap();
650
651 assert_matches!(content, FileOrLocation::File(file_content));
652 assert_eq!(file_content.file, b"some plain text");
653 assert_eq!(file_content.content_type.unwrap(), "text/plain");
654 let content_disposition = file_content.content_disposition.unwrap();
655 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
656 assert_eq!(content_disposition.filename.unwrap(), "ȵ⌾Ⱦԩ💈Ňɠ");
657 }
658}