1use ruma_common::http_headers::ContentDisposition;
6use serde::{Deserialize, Serialize};
7
8pub mod get_content;
9pub mod get_content_thumbnail;
10
11const MULTIPART_MIXED: &str = "multipart/mixed";
13const MAX_HEADERS_COUNT: usize = 32;
15const GENERATED_BOUNDARY_LENGTH: usize = 30;
17
18#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
21pub struct ContentMetadata {}
22
23impl ContentMetadata {
24 pub fn new() -> Self {
26 Self {}
27 }
28}
29
30#[derive(Debug, Clone)]
32#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
33pub enum FileOrLocation {
34 File(Content),
36
37 Location(String),
39}
40
41#[derive(Debug, Clone)]
43#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
44pub struct Content {
45 pub file: Vec<u8>,
47
48 pub content_type: Option<String>,
50
51 pub content_disposition: Option<ContentDisposition>,
54}
55
56impl Content {
57 pub fn new(
59 file: Vec<u8>,
60 content_type: String,
61 content_disposition: ContentDisposition,
62 ) -> Self {
63 Self {
64 file,
65 content_type: Some(content_type),
66 content_disposition: Some(content_disposition),
67 }
68 }
69}
70
71#[cfg(feature = "server")]
75fn try_into_multipart_mixed_response<T: Default + bytes::BufMut>(
76 metadata: &ContentMetadata,
77 content: &FileOrLocation,
78) -> Result<http::Response<T>, ruma_common::api::error::IntoHttpError> {
79 use std::io::Write as _;
80
81 use rand::Rng as _;
82
83 let boundary = rand::thread_rng()
84 .sample_iter(&rand::distributions::Alphanumeric)
85 .map(char::from)
86 .take(GENERATED_BOUNDARY_LENGTH)
87 .collect::<String>();
88
89 let mut body_writer = T::default().writer();
90
91 let _ = write!(
93 body_writer,
94 "\r\n--{boundary}\r\n{}: {}\r\n\r\n",
95 http::header::CONTENT_TYPE,
96 mime::APPLICATION_JSON
97 );
98
99 serde_json::to_writer(&mut body_writer, metadata)?;
101
102 let _ = write!(body_writer, "\r\n--{boundary}\r\n");
104
105 match content {
107 FileOrLocation::File(content) => {
108 let content_type =
110 content.content_type.as_deref().unwrap_or(mime::APPLICATION_OCTET_STREAM.as_ref());
111 let _ = write!(body_writer, "{}: {content_type}\r\n", http::header::CONTENT_TYPE);
112
113 if let Some(content_disposition) = &content.content_disposition {
114 let _ = write!(
115 body_writer,
116 "{}: {content_disposition}\r\n",
117 http::header::CONTENT_DISPOSITION
118 );
119 }
120
121 let _ = body_writer.write_all(b"\r\n");
123
124 let _ = body_writer.write_all(&content.file);
126 }
127 FileOrLocation::Location(location) => {
128 let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION);
130 }
131 }
132
133 let _ = write!(body_writer, "\r\n--{boundary}--");
135
136 let content_type = format!("{MULTIPART_MIXED}; boundary={boundary}");
137 let body = body_writer.into_inner();
138
139 Ok(http::Response::builder().header(http::header::CONTENT_TYPE, content_type).body(body)?)
140}
141
142#[cfg(feature = "client")]
145fn try_from_multipart_mixed_response<T: AsRef<[u8]>>(
146 http_response: http::Response<T>,
147) -> Result<
148 (ContentMetadata, FileOrLocation),
149 ruma_common::api::error::FromHttpResponseError<ruma_common::api::error::MatrixError>,
150> {
151 use ruma_common::api::error::{HeaderDeserializationError, MultipartMixedDeserializationError};
152
153 let body_content_type = http_response
155 .headers()
156 .get(http::header::CONTENT_TYPE)
157 .ok_or_else(|| HeaderDeserializationError::MissingHeader("Content-Type".to_owned()))?
158 .to_str()?
159 .parse::<mime::Mime>()
160 .map_err(|e| HeaderDeserializationError::InvalidHeader(e.into()))?;
161
162 if !body_content_type.essence_str().eq_ignore_ascii_case(MULTIPART_MIXED) {
163 return Err(HeaderDeserializationError::InvalidHeaderValue {
164 header: "Content-Type".to_owned(),
165 expected: MULTIPART_MIXED.to_owned(),
166 unexpected: body_content_type.essence_str().to_owned(),
167 }
168 .into());
169 }
170
171 let boundary = body_content_type
172 .get_param("boundary")
173 .ok_or(HeaderDeserializationError::MissingMultipartBoundary)?
174 .as_str()
175 .as_bytes();
176
177 let body = http_response.body().as_ref();
179
180 let mut full_boundary = Vec::with_capacity(boundary.len() + 4);
181 full_boundary.extend_from_slice(b"\r\n--");
182 full_boundary.extend_from_slice(boundary);
183 let full_boundary_no_crlf = full_boundary.strip_prefix(b"\r\n").unwrap();
184
185 let mut boundaries = memchr::memmem::find_iter(body, &full_boundary);
186
187 let metadata_start = if body.starts_with(full_boundary_no_crlf) {
188 full_boundary_no_crlf.len()
191 } else {
192 boundaries.next().ok_or_else(|| MultipartMixedDeserializationError::MissingBodyParts {
193 expected: 2,
194 found: 0,
195 })? + full_boundary.len()
196 };
197 let metadata_end = boundaries.next().ok_or_else(|| {
198 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 0 }
199 })?;
200
201 let (_raw_metadata_headers, serialized_metadata) =
202 parse_multipart_body_part(body, metadata_start, metadata_end)?;
203
204 let metadata = serde_json::from_slice(serialized_metadata)?;
206
207 let content_start = metadata_end + full_boundary.len();
209 let content_end = boundaries.next().ok_or_else(|| {
210 MultipartMixedDeserializationError::MissingBodyParts { expected: 2, found: 1 }
211 })?;
212
213 let (raw_content_headers, file) = parse_multipart_body_part(body, content_start, content_end)?;
214
215 let mut content_headers = [httparse::EMPTY_HEADER; MAX_HEADERS_COUNT];
217 httparse::parse_headers(raw_content_headers, &mut content_headers)
218 .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?;
219
220 let mut location = None;
221 let mut content_type = None;
222 let mut content_disposition = None;
223 for header in content_headers {
224 if header.name.is_empty() {
225 break;
227 }
228
229 if header.name == http::header::LOCATION {
230 location = Some(
231 String::from_utf8(header.value.to_vec())
232 .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
233 );
234
235 break;
237 } else if header.name == http::header::CONTENT_TYPE {
238 content_type = Some(
239 String::from_utf8(header.value.to_vec())
240 .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
241 );
242 } else if header.name == http::header::CONTENT_DISPOSITION {
243 content_disposition = Some(
244 ContentDisposition::try_from(header.value)
245 .map_err(|e| MultipartMixedDeserializationError::InvalidHeader(e.into()))?,
246 );
247 }
248 }
249
250 let content = if let Some(location) = location {
251 FileOrLocation::Location(location)
252 } else {
253 FileOrLocation::File(Content { file: file.to_owned(), content_type, content_disposition })
254 };
255
256 Ok((metadata, content))
257}
258
259#[cfg(feature = "client")]
264fn parse_multipart_body_part(
265 bytes: &[u8],
266 start: usize,
267 end: usize,
268) -> Result<(&[u8], &[u8]), ruma_common::api::error::MultipartMixedDeserializationError> {
269 use ruma_common::api::error::MultipartMixedDeserializationError;
270
271 let headers_start = memchr::memchr(b'\n', &bytes[start..end])
274 .expect("the end boundary contains a newline")
275 + start
276 + 1;
277
278 let mut line_start = headers_start;
280 let mut line_end;
281
282 loop {
283 line_end = memchr::memchr(b'\n', &bytes[line_start..end])
284 .ok_or(MultipartMixedDeserializationError::MissingBodyPartInnerSeparator)?
285 + line_start
286 + 1;
287
288 if matches!(&bytes[line_start..line_end], b"\r\n" | b"\n") {
289 break;
290 }
291
292 line_start = line_end;
293 }
294
295 Ok((&bytes[headers_start..line_start], &bytes[line_end..end]))
296}
297
298#[cfg(all(test, feature = "client", feature = "server"))]
299mod tests {
300 use assert_matches2::assert_matches;
301 use ruma_common::http_headers::{ContentDisposition, ContentDispositionType};
302
303 use super::{
304 try_from_multipart_mixed_response, try_into_multipart_mixed_response, Content,
305 ContentMetadata, FileOrLocation,
306 };
307
308 #[test]
309 fn multipart_mixed_content_ascii_filename_conversions() {
310 let file = "s⌽me UTF-8 Ťext".as_bytes();
311 let content_type = "text/plain";
312 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
313 .with_filename(Some("filename.txt".to_owned()));
314
315 let outgoing_metadata = ContentMetadata::new();
316 let outgoing_content = FileOrLocation::File(Content {
317 file: file.to_vec(),
318 content_type: Some(content_type.to_owned()),
319 content_disposition: Some(content_disposition.clone()),
320 });
321
322 let response =
323 try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
324 .unwrap();
325
326 let (_incoming_metadata, incoming_content) =
327 try_from_multipart_mixed_response(response).unwrap();
328
329 assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
330 assert_eq!(incoming_content.file, file);
331 assert_eq!(incoming_content.content_type.unwrap(), content_type);
332 assert_eq!(incoming_content.content_disposition, Some(content_disposition));
333 }
334
335 #[test]
336 fn multipart_mixed_content_utf8_filename_conversions() {
337 let file = "s⌽me UTF-8 Ťext".as_bytes();
338 let content_type = "text/plain";
339 let content_disposition = ContentDisposition::new(ContentDispositionType::Attachment)
340 .with_filename(Some("fȈlƩnąmǝ.txt".to_owned()));
341
342 let outgoing_metadata = ContentMetadata::new();
343 let outgoing_content = FileOrLocation::File(Content {
344 file: file.to_vec(),
345 content_type: Some(content_type.to_owned()),
346 content_disposition: Some(content_disposition.clone()),
347 });
348
349 let response =
350 try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
351 .unwrap();
352
353 let (_incoming_metadata, incoming_content) =
354 try_from_multipart_mixed_response(response).unwrap();
355
356 assert_matches!(incoming_content, FileOrLocation::File(incoming_content));
357 assert_eq!(incoming_content.file, file);
358 assert_eq!(incoming_content.content_type.unwrap(), content_type);
359 assert_eq!(incoming_content.content_disposition, Some(content_disposition));
360 }
361
362 #[test]
363 fn multipart_mixed_location_conversions() {
364 let location = "https://server.local/media/filename.txt";
365
366 let outgoing_metadata = ContentMetadata::new();
367 let outgoing_content = FileOrLocation::Location(location.to_owned());
368
369 let response =
370 try_into_multipart_mixed_response::<Vec<u8>>(&outgoing_metadata, &outgoing_content)
371 .unwrap();
372
373 let (_incoming_metadata, incoming_content) =
374 try_from_multipart_mixed_response(response).unwrap();
375
376 assert_matches!(incoming_content, FileOrLocation::Location(incoming_location));
377 assert_eq!(incoming_location, location);
378 }
379
380 #[test]
381 fn multipart_mixed_deserialize_invalid() {
382 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--";
384 let response = http::Response::builder()
385 .header(http::header::CONTENT_TYPE, "multipart/mixed")
386 .body(body)
387 .unwrap();
388
389 try_from_multipart_mixed_response(response).unwrap_err();
390
391 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--";
393 let response = http::Response::builder()
394 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=012345")
395 .body(body)
396 .unwrap();
397
398 try_from_multipart_mixed_response(response).unwrap_err();
399
400 let body =
402 "\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text";
403 let response = http::Response::builder()
404 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
405 .body(body)
406 .unwrap();
407
408 try_from_multipart_mixed_response(response).unwrap_err();
409
410 let body = "\r\n--abcdef\r\n{}\r\n--abcdef\r\nContent-Type: text/plain\r\n\r\nsome plain text\r\n--abcdef--";
412 let response = http::Response::builder()
413 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
414 .body(body)
415 .unwrap();
416
417 try_from_multipart_mixed_response(response).unwrap_err();
418
419 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--";
421 let response = http::Response::builder()
422 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
423 .body(body)
424 .unwrap();
425
426 try_from_multipart_mixed_response(response).unwrap_err();
427
428 let body = "foo--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
430 let response = http::Response::builder()
431 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
432 .body(body)
433 .unwrap();
434
435 try_from_multipart_mixed_response(response).unwrap_err();
436 }
437
438 #[test]
439 fn multipart_mixed_deserialize_valid() {
440 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--";
442 let response = http::Response::builder()
443 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
444 .body(body)
445 .unwrap();
446
447 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
448
449 assert_matches!(content, FileOrLocation::File(file_content));
450 assert_eq!(file_content.file, b"some plain text");
451 assert_eq!(file_content.content_type.unwrap(), "text/plain");
452 assert_eq!(file_content.content_disposition, None);
453
454 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--";
456 let response = http::Response::builder()
457 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
458 .body(body)
459 .unwrap();
460
461 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
462
463 assert_matches!(content, FileOrLocation::File(file_content));
464 assert_eq!(file_content.file, b"some plain text");
465 assert_eq!(file_content.content_type.unwrap(), "text/plain");
466 let content_disposition = file_content.content_disposition.unwrap();
467 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Attachment);
468 assert_eq!(content_disposition.filename.unwrap(), "my_file.txt");
469
470 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-- ";
472 let response = http::Response::builder()
473 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
474 .body(body)
475 .unwrap();
476
477 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
478
479 assert_matches!(content, FileOrLocation::File(file_content));
480 assert_eq!(file_content.file, b"some plain text");
481 assert_eq!(file_content.content_type.unwrap(), "text/plain");
482 assert_eq!(file_content.content_disposition, None);
483
484 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--";
486 let response = http::Response::builder()
487 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
488 .body(body)
489 .unwrap();
490
491 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
492
493 assert_matches!(content, FileOrLocation::File(file_content));
494 assert_eq!(file_content.file, b"some plain text");
495 assert_eq!(file_content.content_type.unwrap(), "text/plain");
496 assert_eq!(file_content.content_disposition, None);
497
498 let body = "--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
500 let response = http::Response::builder()
501 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
502 .body(body)
503 .unwrap();
504
505 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
506
507 assert_matches!(content, FileOrLocation::File(file_content));
508 assert_eq!(file_content.file, b"some plain text");
509 assert_eq!(file_content.content_type, None);
510 assert_eq!(file_content.content_disposition, None);
511
512 let body =
515 "foo--abcdef\r\n--abcdef\r\n\r\n{}\r\n--abcdef\r\n\r\nsome plain text\r\n--abcdef--";
516 let response = http::Response::builder()
517 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
518 .body(body)
519 .unwrap();
520
521 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
522
523 assert_matches!(content, FileOrLocation::File(file_content));
524 assert_eq!(file_content.file, b"some plain text");
525 assert_eq!(file_content.content_type, None);
526 assert_eq!(file_content.content_disposition, None);
527
528 let body = "\r\n--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)
533 .unwrap();
534
535 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
536
537 assert_matches!(content, FileOrLocation::File(file_content));
538 assert_eq!(file_content.file, b"some plain text");
539 assert_eq!(file_content.content_type, None);
540 assert_eq!(file_content.content_disposition, None);
541
542 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--";
544 let response = http::Response::builder()
545 .header(http::header::CONTENT_TYPE, "multipart/mixed; boundary=abcdef")
546 .body(body)
547 .unwrap();
548
549 let (_metadata, content) = try_from_multipart_mixed_response(response).unwrap();
550
551 assert_matches!(content, FileOrLocation::File(file_content));
552 assert_eq!(file_content.file, b"some plain text");
553 assert_eq!(file_content.content_type.unwrap(), "text/plain");
554 let content_disposition = file_content.content_disposition.unwrap();
555 assert_eq!(content_disposition.disposition_type, ContentDispositionType::Inline);
556 assert_eq!(content_disposition.filename.unwrap(), "ȵ⌾Ⱦԩ💈Ňɠ");
557 }
558}