1use serde_json::{Map, Value};
7
8pub const MIN_GALLERY: usize = 2;
11pub const MAX_GALLERY: usize = 20;
13pub const MAX_CAPTION: usize = 200;
15pub const MAX_PREVIEW_LINES: usize = 40;
17pub const MAX_PREVIEW_BYTES: usize = 4096;
19pub const MAX_FILE_NAME: usize = 255;
21
22pub 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
41fn 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
57fn 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
81pub(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
106pub(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
138pub(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
157pub 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 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 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
186pub 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 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 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 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 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}