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