Skip to main content

ruma_federation_api/
authenticated_media.rs

1//! Authenticated endpoints for the content repository, according to [MSC3916].
2//!
3//! [MSC3916]: https://github.com/matrix-org/matrix-spec-proposals/pull/3916
4
5use ruma_common::http_headers::ContentDisposition;
6use serde::{Deserialize, Serialize};
7
8pub mod get_content;
9pub mod get_content_thumbnail;
10
11/// The `multipart/mixed` mime "essence".
12const MULTIPART_MIXED: &str = "multipart/mixed";
13/// The maximum number of headers to parse in a body part.
14#[cfg(feature = "client")]
15const MAX_HEADERS_COUNT: usize = 32;
16/// The length of the generated boundary.
17#[cfg(feature = "server")]
18const GENERATED_BOUNDARY_LENGTH: usize = 30;
19
20/// The metadata of a file from the content repository.
21#[derive(Debug, Clone, Default, Serialize, Deserialize)]
22#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
23pub struct ContentMetadata {}
24
25impl ContentMetadata {
26    /// Creates a new empty `ContentMetadata`.
27    pub fn new() -> Self {
28        Self {}
29    }
30}
31
32/// A file from the content repository or the location where it can be found.
33#[derive(Debug, Clone)]
34#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
35pub enum FileOrLocation {
36    /// The content of the file.
37    File(Content),
38
39    /// The file is at the given URL.
40    Location(String),
41}
42
43/// The content of a file from the content repository.
44#[derive(Debug, Clone)]
45#[cfg_attr(not(ruma_unstable_exhaustive_types), non_exhaustive)]
46pub struct Content {
47    /// The content of the file as bytes.
48    pub file: Vec<u8>,
49
50    /// The content type of the file that was previously uploaded.
51    pub content_type: Option<String>,
52
53    /// The value of the `Content-Disposition` HTTP header, possibly containing the name of the
54    /// file that was previously uploaded.
55    pub content_disposition: Option<ContentDisposition>,
56}
57
58impl Content {
59    /// Creates a new `Content` with the given bytes.
60    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/// Serialize the given metadata and content into a `http::Response` `multipart/mixed` body.
74///
75/// Returns a tuple containing the boundary used
76#[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    // Add first boundary separator and header for the metadata.
94    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    // Add serialized metadata.
102    serde_json::to_writer(&mut body_writer, metadata)?;
103
104    // Add second boundary separator.
105    let _ = write!(body_writer, "\r\n--{boundary}\r\n");
106
107    // Add content.
108    match content {
109        FileOrLocation::File(content) => {
110            // Add headers.
111            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            // Add empty line separator after headers.
124            let _ = body_writer.write_all(b"\r\n");
125
126            // Add bytes.
127            let _ = body_writer.write_all(&content.file);
128        }
129        FileOrLocation::Location(location) => {
130            // Only add location header and empty line separator.
131            let _ = write!(body_writer, "{}: {location}\r\n\r\n", http::header::LOCATION);
132        }
133    }
134
135    // Add final boundary.
136    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/// Deserialize the given metadata and content from a `http::Response` with a `multipart/mixed`
145/// body.
146#[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    // First, get the boundary from the content type header.
153    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    // Split the body with the boundary.
177    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        // If there is no preamble before the first boundary, it may omit the
188        // preceding CRLF.
189        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    // Don't search for anything in the headers, just deserialize the content that should be JSON.
204    let metadata = serde_json::from_slice(serialized_metadata)?;
205
206    // Look at the part containing the media content now.
207    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    // Parse the headers to retrieve the content type and content disposition.
215    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            // This is a empty header, we have reached the end of the parsed headers.
225            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            // This is the only header we need, stop parsing.
235            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/// Parse the multipart body part in the given bytes, starting and ending at the given positions.
259///
260/// Returns a `(headers_bytes, content_bytes)` tuple. Returns an error if the separation between the
261/// headers and the content could not be found.
262#[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    // The part should start with a newline after the boundary. We need to ignore characters before
271    // it in case of extra whitespaces, and for compatibility it might not have a CR.
272    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's find an empty line now.
278    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        // Missing boundary in headers.
388        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        // Wrong boundary.
397        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        // Missing boundary in body.
406        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        // Missing header and content empty line separator in body part.
416        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        // Control character in header.
425        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        // Boundary without CRLF with preamble.
434        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        // Simple.
446        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        // Case-insensitive headers.
460        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        // Extra whitespace.
476        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        // Missing CR except in boundaries.
490        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        // No leading CRLF (and no preamble)
504        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        // Boundary text in preamble, but no leading CRLF, so it should be
518        // ignored.
519        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        // No body part headers.
534        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        // Raw UTF-8 filename (some kind of compatibility with multipart/form-data).
548        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}