1use std::ffi::OsStr;
6use std::io::{Read, Seek, SeekFrom, Write};
7use std::path::Path;
8use std::process::{Command, Stdio};
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::time::{Duration, Instant};
11
12const TIMEOUT: Duration = Duration::from_secs(8);
13static NO_BACKEND_LOGGED: AtomicBool = AtomicBool::new(false);
14const BACKENDS: &[(&str, &[&str])] = &[
15 ("magick", &["-", "webp:-"]),
16 ("convert", &["-", "webp:-"]),
17 ("gm", &["convert", "-", "webp:-"]),
18 (
19 "ffmpeg",
20 &[
21 "-y",
22 "-loglevel",
23 "error",
24 "-i",
25 "-",
26 "-f",
27 "webp",
28 "pipe:1",
29 ],
30 ),
31 (
32 "avconv",
33 &[
34 "-y",
35 "-loglevel",
36 "error",
37 "-i",
38 "-",
39 "-f",
40 "webp",
41 "pipe:1",
42 ],
43 ),
44];
45
46fn select_backend(
47 forced: Option<&OsStr>,
48 available: impl Fn(&str) -> bool,
49) -> Option<(&'static str, &'static [&'static str])> {
50 BACKENDS
51 .iter()
52 .copied()
53 .find(|(name, _)| forced.is_none_or(|forced| forced == OsStr::new(name)) && available(name))
54}
55
56fn executable_available(name: &str) -> bool {
57 std::env::var_os("PATH").is_some_and(|path| {
58 std::env::split_paths(&path).any(|directory| {
59 let path = directory.join(name);
60 let Ok(metadata) = path.metadata() else {
61 return false;
62 };
63 if !metadata.is_file() {
64 return false;
65 }
66 #[cfg(unix)]
67 {
68 use std::os::unix::fs::PermissionsExt;
69 metadata.permissions().mode() & 0o111 != 0
70 }
71 #[cfg(not(unix))]
72 {
73 true
74 }
75 })
76 })
77}
78
79pub(crate) fn convert_to_webp(input: &[u8]) -> Option<Vec<u8>> {
80 convert_with_options(
81 input,
82 ConversionOptions {
83 quality: None,
84 ..Default::default()
85 },
86 )
87}
88
89#[derive(Debug, Clone, Copy)]
91pub struct ConversionOptions {
92 pub quality: Option<i32>,
94 pub max_dimension: Option<u32>,
96 pub timeout: Duration,
97}
98
99impl Default for ConversionOptions {
100 fn default() -> Self {
101 Self {
102 quality: Some(85),
103 max_dimension: None,
104 timeout: TIMEOUT,
105 }
106 }
107}
108
109pub fn convert_file_to_webp(
113 source: impl AsRef<Path>,
114 options: ConversionOptions,
115) -> Option<tempfile::NamedTempFile> {
116 let input = std::fs::read(source).ok()?;
117 let output = convert_with_options(&input, options)?;
118 let mut file = tempfile::Builder::new()
119 .prefix("rns_media_")
120 .suffix(".webp")
121 .tempfile()
122 .ok()?;
123 file.write_all(&output).ok()?;
124 file.seek(SeekFrom::Start(0)).ok()?;
125 Some(file)
126}
127
128fn configured_args(name: &str, args: &[&str], options: ConversionOptions) -> Vec<String> {
129 let mut result: Vec<String> = args.iter().map(|arg| (*arg).to_owned()).collect();
130 let mut extra = Vec::new();
131 if let Some(quality) = options.quality {
132 extra.extend(["-quality".into(), quality.clamp(1, 100).to_string()]);
133 }
134 let image_magick = matches!(name, "magick" | "convert" | "gm");
135 if let Some(dimension) = options.max_dimension.filter(|dimension| *dimension > 0) {
136 if image_magick {
137 extra.extend(["-resize".into(), format!("{dimension}x{dimension}>")]);
138 } else {
139 extra.extend(["-vf".into(), format!("scale='min(iw,{dimension})':'min(ih,{dimension})':force_original_aspect_ratio=decrease")]);
140 }
141 }
142 let index = if image_magick {
143 result.len() - 1
144 } else {
145 result
146 .iter()
147 .position(|arg| arg == "-f")
148 .unwrap_or(result.len())
149 };
150 result.splice(index..index, extra);
151 result
152}
153
154fn convert_with_options(input: &[u8], options: ConversionOptions) -> Option<Vec<u8>> {
155 let forced = std::env::var_os("RNGIT_MEDIA_BACKEND").filter(|value| !value.is_empty());
156 let Some((name, args)) = select_backend(forced.as_deref(), executable_available) else {
157 if !NO_BACKEND_LOGGED.swap(true, Ordering::Relaxed) {
158 log::warn!("No WebP encoding backend available; install ImageMagick or ffmpeg to enable media conversion");
159 }
160 return None;
161 };
162 NO_BACKEND_LOGGED.store(false, Ordering::Relaxed);
163 match convert(
164 input,
165 Command::new(name).args(configured_args(name, args, options)),
166 options.timeout,
167 ) {
168 Ok(output) => Some(output),
169 Err(err) => {
170 log::warn!("Media conversion via {name} failed: {err}; serving original media");
171 None
172 }
173 }
174}
175
176fn convert(input: &[u8], command: &mut Command, timeout: Duration) -> crate::Result<Vec<u8>> {
177 let mut source = tempfile::tempfile()?;
178 source.write_all(input)?;
179 source.seek(SeekFrom::Start(0))?;
180 let mut output = tempfile::tempfile()?;
181 let mut error = tempfile::tempfile()?;
182 let mut child = command
183 .stdin(Stdio::from(source))
184 .stdout(Stdio::from(output.try_clone()?))
185 .stderr(Stdio::from(error.try_clone()?))
186 .spawn()?;
187 let deadline = Instant::now() + timeout;
188 let status = loop {
189 match child.try_wait() {
190 Ok(Some(status)) => break status,
191 Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(10)),
192 result => {
193 let _ = child.kill();
194 let _ = child.wait();
195 return Err(crate::Error::msg(match result {
196 Err(err) => err.to_string(),
197 _ => "media conversion timed out".into(),
198 }));
199 }
200 }
201 };
202 if !status.success() {
203 error.seek(SeekFrom::Start(0))?;
204 let mut details = Vec::new();
205 error.take(1024).read_to_end(&mut details)?;
206 return Err(crate::Error::msg(format!(
207 "{status}: {}",
208 String::from_utf8_lossy(&details)
209 )));
210 }
211 output.seek(SeekFrom::Start(0))?;
212 let mut bytes = Vec::new();
213 output.read_to_end(&mut bytes)?;
214 if webp_dimensions(&bytes).is_none() {
215 return Err(crate::Error::msg("invalid WebP output"));
216 }
217 Ok(bytes)
218}
219
220fn webp_dimensions(data: &[u8]) -> Option<(u32, u32)> {
221 if data.len() < 30 || &data[..4] != b"RIFF" || &data[8..12] != b"WEBP" {
222 return None;
223 }
224 let (width, height) = match &data[12..16] {
225 b"VP8X" => (
226 u32::from_le_bytes([data[24], data[25], data[26], 0]) + 1,
227 u32::from_le_bytes([data[27], data[28], data[29], 0]) + 1,
228 ),
229 b"VP8 " => (
230 (u16::from_le_bytes([data[26], data[27]]) & 0x3fff) as u32,
231 (u16::from_le_bytes([data[28], data[29]]) & 0x3fff) as u32,
232 ),
233 b"VP8L" => {
234 let bits = u32::from_le_bytes(data[21..25].try_into().ok()?);
235 ((bits & 0x3fff) + 1, ((bits >> 14) & 0x3fff) + 1)
236 }
237 _ => return None,
238 };
239 (width > 0 && height > 0).then_some((width, height))
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245
246 #[test]
247 fn backend_options_clamp_quality_and_disable_zero_resize() {
248 for (name, args) in BACKENDS {
249 let configured = configured_args(
250 name,
251 args,
252 ConversionOptions {
253 quality: Some(200),
254 max_dimension: Some(0),
255 ..Default::default()
256 },
257 );
258 assert!(configured
259 .windows(2)
260 .any(|pair| pair == ["-quality", "100"]));
261 assert!(!configured
262 .iter()
263 .any(|arg| arg == "-resize" || arg == "-vf"));
264 let configured = configured_args(
265 name,
266 args,
267 ConversionOptions {
268 quality: Some(-5),
269 max_dimension: Some(640),
270 ..Default::default()
271 },
272 );
273 assert!(configured.windows(2).any(|pair| pair == ["-quality", "1"]));
274 assert!(configured.iter().any(|arg| arg.contains("640")));
275 assert_eq!(configured.last().unwrap(), args.last().unwrap());
276 }
277 }
278
279 #[test]
280 #[ignore = "requires an installed WebP backend"]
281 fn file_conversion_resizes_without_upscaling_and_owns_cleanup() {
282 let mut source = tempfile::NamedTempFile::new().unwrap();
283 let pixels =
284 b"P6\n4 2\n255\n\xff\0\0\0\xff\0\xff\0\0\0\xff\0\xff\0\0\0\xff\0\xff\0\0\0\xff\0";
285 source.write_all(pixels).unwrap();
286 for (limit, dimensions) in [(2, (2, 1)), (8, (4, 2))] {
287 let output = convert_file_to_webp(
288 source.path(),
289 ConversionOptions {
290 max_dimension: Some(limit),
291 ..Default::default()
292 },
293 )
294 .unwrap();
295 let path = output.path().to_owned();
296 assert_eq!(
297 webp_dimensions(&std::fs::read(&path).unwrap()),
298 Some(dimensions)
299 );
300 drop(output);
301 assert!(!path.exists());
302 assert_eq!(std::fs::read(source.path()).unwrap(), pixels);
303 }
304 std::fs::write(source.path(), b"not an image").unwrap();
305 assert!(convert_file_to_webp(source.path(), Default::default()).is_none());
306 assert!(convert_file_to_webp(source.path().join("missing"), Default::default()).is_none());
307 }
308
309 #[test]
310 fn backend_preference_and_forced_selection() {
311 assert_eq!(select_backend(None, |_| true).unwrap().0, "magick");
312 assert_eq!(
313 select_backend(None, |name| name == "ffmpeg").unwrap().0,
314 "ffmpeg"
315 );
316 assert_eq!(
317 select_backend(Some(OsStr::new("gm")), |_| true).unwrap().0,
318 "gm"
319 );
320 assert!(select_backend(Some(OsStr::new("unknown")), |_| true).is_none());
321 assert!(select_backend(Some(OsStr::new("magick")), |name| name == "convert").is_none());
322 }
323
324 #[test]
325 fn validates_all_webp_headers_and_rejects_invalid_output() {
326 let mut bytes = vec![0; 30];
327 bytes[..4].copy_from_slice(b"RIFF");
328 bytes[8..12].copy_from_slice(b"WEBP");
329 for header in [b"VP8X", b"VP8L"] {
330 bytes[12..16].copy_from_slice(header);
331 assert_eq!(webp_dimensions(&bytes), Some((1, 1)));
332 }
333 bytes[12..16].copy_from_slice(b"VP8 ");
334 assert_eq!(webp_dimensions(&bytes), None);
335 bytes[26] = 2;
336 bytes[28] = 3;
337 assert_eq!(webp_dimensions(&bytes), Some((2, 3)));
338 assert_eq!(webp_dimensions(&bytes[..29]), None);
339 bytes[0] = 0;
340 assert_eq!(webp_dimensions(&bytes), None);
341 }
342
343 #[test]
344 fn conversion_rejects_invalid_output_failure_and_timeout() {
345 assert!(convert(b"invalid", &mut Command::new("cat"), TIMEOUT).is_err());
346 assert!(convert(b"", &mut Command::new("false"), TIMEOUT).is_err());
347 let start = Instant::now();
348 assert!(convert(
349 b"",
350 Command::new("sleep").arg("5"),
351 Duration::from_millis(30)
352 )
353 .unwrap_err()
354 .to_string()
355 .contains("timed out"));
356 assert!(start.elapsed() < Duration::from_secs(1));
357 }
358
359 #[test]
360 #[ignore = "requires an installed WebP backend"]
361 fn real_backend_converts_pixels() {
362 let pixels = b"P6\n2 1\n255\n\xff\0\0\0\xff\0";
363 let output = convert_to_webp(pixels).expect("available backend must convert pixels");
364 assert_eq!(webp_dimensions(&output), Some((2, 1)));
365 }
366}