Skip to main content

mxblocks/
media.rs

1//! `gallery` and `file` blocks: media referenced like Matrix's own `m.image`
2//! / `m.file` content, as a plain `url` (`mxc://`) or an encrypted `file`
3//! (`EncryptedFile`: url, key, iv, hashes, v) in E2EE rooms. Shapes and
4//! limits: `docs/spec.md`.
5
6use serde_json::{Map, Value};
7
8/// Images in a `gallery` (validation refuses fewer or more; renderers draw 1 and
9/// drop past 20).
10pub const MIN_GALLERY: usize = 2;
11/// Most images in a `gallery`.
12pub const MAX_GALLERY: usize = 20;
13/// Captions, alt texts and gallery titles, in characters.
14pub const MAX_CAPTION: usize = 200;
15/// A `file` block's `preview`: the first lines of a text file.
16pub const MAX_PREVIEW_LINES: usize = 40;
17/// A `file` block's `preview`, in bytes (UTF-8).
18pub const MAX_PREVIEW_BYTES: usize = 4096;
19/// A file name, in characters.
20pub const MAX_FILE_NAME: usize = 255;
21
22/// `mxc://server/mediaId` (the renderer's check, byte for byte).
23pub fn is_mxc(s: &str) -> bool {
24    let Some(rest) = s.strip_prefix("mxc://") else {
25        return false;
26    };
27    let Some((server, id)) = rest.split_once('/') else {
28        return false;
29    };
30    s.len() <= 255
31        && !server.is_empty()
32        && server
33            .chars()
34            .all(|c| c.is_ascii_alphanumeric() || ".:-[]".contains(c))
35        && !id.is_empty()
36        && id
37            .chars()
38            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
39}
40
41/// An `EncryptedFile` (attachment encryption v2) as Matrix carries it.
42fn is_encrypted_file(v: &Value) -> bool {
43    let s = |k: &str| v.get(k).and_then(Value::as_str);
44    s("url").is_some_and(is_mxc)
45        && s("v") == Some("v2")
46        && s("iv").is_some_and(|x| !x.is_empty())
47        && v.get("key")
48            .and_then(|k| k.get("k"))
49            .and_then(Value::as_str)
50            .is_some_and(|x| !x.is_empty())
51        && v.get("hashes")
52            .and_then(|h| h.get("sha256"))
53            .and_then(Value::as_str)
54            .is_some_and(|x| !x.is_empty())
55}
56
57/// A media source: `url_key` (plain mxc) or `file_key` (EncryptedFile), not
58/// both; one is needed when `required`.
59fn validate_source(
60    o: &Map<String, Value>,
61    url_key: &str,
62    file_key: &str,
63    required: bool,
64    what: &str,
65) -> Result<(), String> {
66    match (o.get(url_key), o.get(file_key)) {
67        (Some(_), Some(_)) => Err(format!("{what}: {url_key:?} or {file_key:?}, not both")),
68        (Some(u), None) if !u.as_str().is_some_and(is_mxc) => Err(format!(
69            "{what}: {url_key:?} must be an mxc://server/mediaId URI"
70        )),
71        (None, Some(f)) if !is_encrypted_file(f) => Err(format!(
72            "{what}: {file_key:?} must be an EncryptedFile (url, key.k, iv, hashes.sha256, v \"v2\")"
73        )),
74        (None, None) if required => Err(format!(
75            "{what}: needs \"{url_key}\" (mxc://) or \"{file_key}\" (encrypted)"
76        )),
77        _ => Ok(()),
78    }
79}
80
81/// An optional one-line string of 1..=`max` characters.
82pub(super) fn opt_text(
83    o: &Map<String, Value>,
84    key: &str,
85    max: usize,
86    what: &str,
87) -> Result<(), String> {
88    match o.get(key) {
89        None => Ok(()),
90        Some(Value::String(s)) if !s.trim().is_empty() && s.chars().count() <= max => Ok(()),
91        Some(_) => Err(format!("{what}: {key:?} is text of 1 to {max} characters")),
92    }
93}
94
95fn opt_uint(o: &Map<String, Value>, key: &str, positive: bool, what: &str) -> Result<(), String> {
96    match o.get(key) {
97        None => Ok(()),
98        Some(v) if v.as_u64().is_some_and(|n| !positive || n > 0) => Ok(()),
99        Some(_) => Err(format!(
100            "{what}: {key:?} is a {} integer",
101            if positive { "positive" } else { "non-negative" }
102        )),
103    }
104}
105
106/// `{type: "gallery", title?, images: [{url | file, caption?, alt?, w?, h?, mimetype?, size?}]}`.
107pub(super) fn validate_gallery(m: &Map<String, Value>) -> Result<(), String> {
108    opt_text(m, "title", MAX_CAPTION, "gallery block")?;
109    let images = m
110        .get("images")
111        .and_then(Value::as_array)
112        .map_or(&[][..], |a| a);
113    if images.len() < MIN_GALLERY || images.len() > MAX_GALLERY {
114        return Err(format!(
115            "gallery block: {MIN_GALLERY} to {MAX_GALLERY} images (one image is an m.image)"
116        ));
117    }
118    for (i, img) in images.iter().enumerate() {
119        let what = format!("gallery image {}", i + 1);
120        let o = img.as_object().ok_or(format!("{what}: an object"))?;
121        validate_source(o, "url", "file", true, &what)?;
122        opt_text(o, "caption", MAX_CAPTION, &what)?;
123        opt_text(o, "alt", MAX_CAPTION, &what)?;
124        opt_uint(o, "w", true, &what)?;
125        opt_uint(o, "h", true, &what)?;
126        opt_uint(o, "size", false, &what)?;
127        if let Some(mt) = o.get("mimetype")
128            && !mt
129                .as_str()
130                .is_some_and(|s| s.starts_with("image/") && s.len() <= 100)
131        {
132            return Err(format!("{what}: \"mimetype\" is an image/* type"));
133        }
134    }
135    Ok(())
136}
137
138/// `{type: "file", name, size?, mimetype?, url | file, preview?, thumbnail_url | thumbnail_file}`.
139pub(super) fn validate_file(m: &Map<String, Value>) -> Result<(), String> {
140    let what = "file block";
141    opt_text(m, "name", MAX_FILE_NAME, what)?;
142    validate_source(m, "url", "file", true, what)?;
143    validate_source(m, "thumbnail_url", "thumbnail_file", false, what)?;
144    opt_uint(m, "size", false, what)?;
145    opt_text(m, "mimetype", 100, what)?;
146    if let Some(p) = m.get("preview") {
147        let p = p.as_str().ok_or(format!("{what}: \"preview\" is text"))?;
148        if p.len() > MAX_PREVIEW_BYTES || p.lines().count() > MAX_PREVIEW_LINES {
149            return Err(format!(
150                "{what}: \"preview\" is at most {MAX_PREVIEW_LINES} lines and {MAX_PREVIEW_BYTES} bytes"
151            ));
152        }
153    }
154    Ok(())
155}
156
157/// The start of a text file for a `file` block's `preview`: at most
158/// [`MAX_PREVIEW_LINES`] lines and [`MAX_PREVIEW_BYTES`] bytes, cut at a
159/// character boundary. `None` for binary data (a NUL, or invalid UTF-8
160/// before the cut) and for blank text.
161pub fn text_preview(data: &[u8]) -> Option<String> {
162    let head = &data[..data.len().min(MAX_PREVIEW_BYTES)];
163    if head.contains(&0) {
164        return None;
165    }
166    let text = match std::str::from_utf8(head) {
167        Ok(s) => s,
168        // A multi-byte character cut by the byte cap is fine; anything else
169        // is binary.
170        Err(e) if head.len() == MAX_PREVIEW_BYTES && head.len() - e.valid_up_to() < 4 => {
171            std::str::from_utf8(&head[..e.valid_up_to()]).ok()?
172        }
173        Err(_) => return None,
174    };
175    let text = text.replace("\r\n", "\n");
176    let mut out: Vec<&str> = text.split('\n').take(MAX_PREVIEW_LINES).collect();
177    // A last line cut by the byte cap is kept: the renderer marks the preview
178    // as partial when `size` says there's more.
179    while out.last().is_some_and(|l| l.trim().is_empty()) {
180        out.pop();
181    }
182    let s = out.join("\n");
183    (!s.trim().is_empty()).then_some(s)
184}
185
186/// Width and height of a PNG, GIF, JPEG or WebP from its header, for
187/// `info.w`/`info.h` (Element X sizes the image tile before loading it).
188pub fn image_size(d: &[u8]) -> Option<(u32, u32)> {
189    let be16 = |i: usize| Some(u16::from_be_bytes([*d.get(i)?, *d.get(i + 1)?]) as u32);
190    let le16 = |i: usize| Some(u16::from_le_bytes([*d.get(i)?, *d.get(i + 1)?]) as u32);
191    let be32 = |i: usize| Some(u32::from_be_bytes(d.get(i..i + 4)?.try_into().ok()?));
192    let le24 = |i: usize| {
193        Some(u32::from_le_bytes([
194            *d.get(i)?,
195            *d.get(i + 1)?,
196            *d.get(i + 2)?,
197            0,
198        ]))
199    };
200    let size = if d.starts_with(b"\x89PNG\r\n\x1a\n") && d.get(12..16) == Some(b"IHDR") {
201        (be32(16)?, be32(20)?)
202    } else if d.starts_with(b"GIF87a") || d.starts_with(b"GIF89a") {
203        (le16(6)?, le16(8)?)
204    } else if d.starts_with(b"RIFF") && d.get(8..12) == Some(b"WEBP") {
205        match d.get(12..16)? {
206            b"VP8 " => (le16(26)? & 0x3fff, le16(28)? & 0x3fff),
207            b"VP8L" => {
208                let b = u32::from_le_bytes(d.get(21..25)?.try_into().ok()?);
209                ((b & 0x3fff) + 1, ((b >> 14) & 0x3fff) + 1)
210            }
211            b"VP8X" => (le24(24)? + 1, le24(27)? + 1),
212            _ => return None,
213        }
214    } else if d.starts_with(&[0xff, 0xd8]) {
215        // Walk the JPEG segments to the first start-of-frame.
216        let mut i = 2;
217        loop {
218            while *d.get(i)? == 0xff && d.get(i + 1) == Some(&0xff) {
219                i += 1;
220            }
221            if *d.get(i)? != 0xff {
222                return None;
223            }
224            let marker = *d.get(i + 1)?;
225            let len = be16(i + 2)? as usize;
226            let sof = matches!(marker, 0xc0..=0xcf) && !matches!(marker, 0xc4 | 0xc8 | 0xcc);
227            if sof {
228                break (be16(i + 7)?, be16(i + 5)?);
229            }
230            i += 2 + len;
231        }
232    } else {
233        return None;
234    };
235    (size.0 > 0 && size.1 > 0).then_some(size)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use crate::Rich;
242    use serde_json::json;
243
244    fn parse(block: Value) -> Result<Rich, String> {
245        Rich::parse(&json!({"v": 1, "blocks": [block]}).to_string())
246    }
247
248    fn enc(url: &str) -> Value {
249        json!({"url": url, "v": "v2", "iv": "AAAAAAAAAAAAAAAAAAAAAA",
250               "key": {"kty": "oct", "alg": "A256CTR", "ext": true, "key_ops": ["encrypt", "decrypt"], "k": "abc"},
251               "hashes": {"sha256": "xyz"}})
252    }
253
254    #[test]
255    fn mxc_uris() {
256        assert!(is_mxc("mxc://example.org/AbC_d-1"));
257        assert!(is_mxc("mxc://[::1]:8448/x"));
258        for bad in [
259            "https://x/y",
260            "mxc://",
261            "mxc://server",
262            "mxc://server/",
263            "mxc:///id",
264            "mxc://s/a/b",
265            "mxc://s/a b",
266        ] {
267            assert!(!is_mxc(bad), "{bad}");
268        }
269    }
270
271    #[test]
272    fn gallery() {
273        let img = |u: &str| json!({"url": u, "caption": "A cat", "alt": "a cat", "w": 800, "h": 600, "mimetype": "image/jpeg"});
274        let ok = json!({"type": "gallery", "title": "Cats", "images": [
275            img("mxc://s/a"), {"file": enc("mxc://s/b"), "size": 10}]});
276        let r = parse(ok.clone()).unwrap();
277        assert_eq!(serde_json::to_value(&r).unwrap()["blocks"][0], ok);
278
279        let many = json!({"type": "gallery", "images": vec![img("mxc://s/a"); 21]});
280        for (bad, why) in [
281            (
282                json!({"type": "gallery", "images": [img("mxc://s/a")]}),
283                "2 to 20",
284            ),
285            (many, "2 to 20"),
286            (json!({"type": "gallery", "images": []}), "\"images\""),
287            (
288                json!({"type": "gallery", "images": [img("https://x/a.png"), img("mxc://s/b")]}),
289                "mxc://",
290            ),
291            (
292                json!({"type": "gallery", "images": [{"caption": "x"}, img("mxc://s/b")]}),
293                "needs \"url\"",
294            ),
295            (
296                json!({"type": "gallery", "images": [{"url": "mxc://s/a", "file": enc("mxc://s/a")}, img("mxc://s/b")]}),
297                "not both",
298            ),
299            (
300                json!({"type": "gallery", "images": [{"file": {"url": "mxc://s/a"}}, img("mxc://s/b")]}),
301                "EncryptedFile",
302            ),
303            (
304                json!({"type": "gallery", "images": [{"url": "mxc://s/a", "caption": "x".repeat(201)}, img("mxc://s/b")]}),
305                "\"caption\"",
306            ),
307            (
308                json!({"type": "gallery", "images": [{"url": "mxc://s/a", "w": 0}, img("mxc://s/b")]}),
309                "\"w\"",
310            ),
311            (
312                json!({"type": "gallery", "images": [{"url": "mxc://s/a", "mimetype": "text/html"}, img("mxc://s/b")]}),
313                "image/*",
314            ),
315            (
316                json!({"type": "gallery", "images": ["mxc://s/a", img("mxc://s/b")]}),
317                "an object",
318            ),
319        ] {
320            let err = parse(bad).unwrap_err();
321            assert!(err.contains(why), "{why}: {err}");
322        }
323    }
324
325    #[test]
326    fn file() {
327        let ok = json!({"type": "file", "name": "build.log", "size": 5120, "mimetype": "text/plain",
328                        "url": "mxc://s/log", "preview": "line 1\nline 2", "thumbnail_url": "mxc://s/t"});
329        parse(ok).unwrap();
330        parse(json!({"type": "file", "name": "a.pdf", "file": enc("mxc://s/a"), "thumbnail_file": enc("mxc://s/t")})).unwrap();
331        let lines = (0..41)
332            .map(|i| i.to_string())
333            .collect::<Vec<_>>()
334            .join("\n");
335        for (bad, why) in [
336            (json!({"type": "file", "url": "mxc://s/a"}), "\"name\""),
337            (json!({"type": "file", "name": "a"}), "needs \"url\""),
338            (
339                json!({"type": "file", "name": "a", "url": "mxc://s/a", "preview": lines}),
340                "40 lines",
341            ),
342            (
343                json!({"type": "file", "name": "a", "url": "mxc://s/a", "preview": "x".repeat(4097)}),
344                "4096 bytes",
345            ),
346            (
347                json!({"type": "file", "name": "a", "url": "mxc://s/a", "size": -1}),
348                "\"size\"",
349            ),
350            (
351                json!({"type": "file", "name": "a", "url": "mxc://s/a", "thumbnail_url": "https://x"}),
352                "\"thumbnail_url\"",
353            ),
354            (
355                json!({"type": "file", "name": "x".repeat(256), "url": "mxc://s/a"}),
356                "\"name\"",
357            ),
358        ] {
359            let err = parse(bad).unwrap_err();
360            assert!(err.contains(why), "{why}: {err}");
361        }
362    }
363
364    #[test]
365    fn previews() {
366        assert_eq!(
367            text_preview(b"a\r\nb\n\n\n").as_deref(),
368            Some("a\nb"),
369            "CRLF and trailing blank lines"
370        );
371        assert_eq!(text_preview(b"\x00\x01PNG"), None);
372        assert_eq!(text_preview(b"\xff\xfe\xfd garbage"), None);
373        assert_eq!(text_preview(b"  \n "), None);
374        let long: String = (0..100).map(|i| format!("line {i}\n")).collect();
375        let p = text_preview(long.as_bytes()).unwrap();
376        assert_eq!(p.lines().count(), MAX_PREVIEW_LINES);
377        // A multi-byte character cut at the byte cap.
378        let wide = "é".repeat(MAX_PREVIEW_BYTES);
379        let p = text_preview(wide.as_bytes()).unwrap();
380        assert!(p.len() <= MAX_PREVIEW_BYTES && p.chars().all(|c| c == 'é'));
381        // A preview always passes the block check.
382        let block = json!({"type": "file", "name": "a", "url": "mxc://s/a", "preview": p});
383        parse(block).unwrap();
384    }
385
386    #[test]
387    fn image_sizes() {
388        let mut png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\x0dIHDR".to_vec();
389        png.extend(640u32.to_be_bytes());
390        png.extend(480u32.to_be_bytes());
391        assert_eq!(image_size(&png), Some((640, 480)));
392        let gif = b"GIF89a\x20\x03\x58\x02rest";
393        assert_eq!(image_size(gif), Some((800, 600)));
394        // JPEG: SOI, an APP0 segment, then SOF0 (precision, h, w).
395        let jpeg = [
396            0xff, 0xd8, 0xff, 0xe0, 0x00, 0x04, 0x00, 0x00, 0xff, 0xc0, 0x00, 0x11, 0x08, 0x01,
397            0xe0, 0x02, 0x80,
398        ];
399        assert_eq!(image_size(&jpeg), Some((640, 480)));
400        let mut webp = b"RIFF\x00\x00\x00\x00WEBPVP8X\x0a\x00\x00\x00\x00\x00\x00\x00".to_vec();
401        webp.extend([0x7f, 0x02, 0x00, 0xdf, 0x01, 0x00]);
402        assert_eq!(image_size(&webp), Some((640, 480)));
403        assert_eq!(image_size(b"not an image"), None);
404        assert_eq!(image_size(&[0xff, 0xd8, 0xff]), None);
405    }
406}